From 08d333f63c3b3cc968db9a49c74ce37a8c67e531 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:19:15 -0400 Subject: [PATCH 001/163] [save-edl] Remove PTS correction in end timestamp calculation #516 --- scenedetect/_cli/commands.py | 2 +- tests/test_cli.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 3d747fa0..38fa4679 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -292,7 +292,7 @@ def get_edl_timecode(timecode: FrameTimecode): # Add each shot as an edit entry for i, (start, end) in enumerate(scenes): in_tc = get_edl_timecode(start) - out_tc = get_edl_timecode(end - 1) # Correct for presentation time + out_tc = get_edl_timecode(end) # Correct for presentation time # Format the edit entry according to CMX 3600 format event_line = f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}" edl_content.append(event_line) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d7280ad..533f6f9b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -104,7 +104,7 @@ def invoke_scenedetect( if config_file: command += " -c %s" % config_file command += " " + args.format(**value_dict) - return subprocess.call(command.strip().split(" ")) + return subprocess.call(command.strip().split(" "), shell=True) def test_cli_no_args(): @@ -757,8 +757,8 @@ def test_cli_save_edl(tmp_path: Path): TITLE: {DEFAULT_VIDEO_NAME} FCM: NON-DROP FRAME -001 AX V C 00:00:02:00 00:00:03:17 00:00:02:00 00:00:03:17 -002 AX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 +001 AX V C 00:00:02:00 00:00:03:18 00:00:02:00 00:00:03:18 +002 AX V C 00:00:03:18 00:00:06:00 00:00:03:18 00:00:06:00 """ assert output_path.read_text() == EXPECTED_EDL_OUTPUT @@ -778,8 +778,8 @@ def test_cli_save_edl_with_params(tmp_path: Path): TITLE: title FCM: NON-DROP FRAME -001 BX V C 00:00:02:00 00:00:03:17 00:00:02:00 00:00:03:17 -002 BX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 +001 BX V C 00:00:02:00 00:00:03:18 00:00:02:00 00:00:03:18 +002 BX V C 00:00:03:18 00:00:06:00 00:00:03:18 00:00:06:00 """ assert output_path.read_text() == EXPECTED_EDL_OUTPUT From c483d7b28a94b10189771429b0c3c435cb27a0d5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:23:06 -0400 Subject: [PATCH 002/163] [dist] Update ffmpeg 7.1 -> 8.0 --- appveyor.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 4029ec9a..2758a834 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -20,6 +20,7 @@ environment: secure: of3o1pInqCJYwKLFsiadbsRYazCmCuZq7r2roaYvYXmBvm6e6JHsRU47waylTmhm ai_license_salt: secure: +NKWwlkEptlThgfeL35pLo7EsnkJc+4WODm8tTg1aO5fc0duQ4r100fHQYj6nzhyUdy3Dhs/mOLkxD8rNbBiEQ== + ffmpeg_version: "8.0" # SignPath Config for Code Signing deploy: @@ -41,7 +42,7 @@ install: - python -m pip install --upgrade -r dist/requirements_windows.txt --no-binary imageio-ffmpeg # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-full_build.7z + - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-7.1-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' @@ -72,7 +73,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.5\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.9.1\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer From 26f9cbaf3d473e3698450e24381db273a1a47e18 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:24:43 -0400 Subject: [PATCH 003/163] [releases] Prepare v0.6.7 --- .github/workflows/generate-docs.yml | 2 +- README.md | 2 +- appveyor.yml | 2 +- dist/installer/PySceneDetect.aip | 8 ++++---- scenedetect/__init__.py | 2 +- website/pages/changelog.md | 13 +++++++++++++ website/pages/docs.md | 1 + website/pages/download.md | 8 ++++---- website/pages/index.md | 2 +- 9 files changed, 27 insertions(+), 13 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index e64076b1..74b20deb 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -16,7 +16,7 @@ jobs: env: # TODO: Figure out a better way to handle figuring out what version /latest should be, # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.6' + scenedetect_docs_latest: '0.6.7' scenedetect_docs_dest: '' steps: diff --git a/README.md b/README.md index 012c1225..db5be2db 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.6 (March 9, 2025) +### Latest Release: v0.6.7 (August 24, 2025) **Website**: [scenedetect.com](https://www.scenedetect.com) diff --git a/appveyor.yml b/appveyor.yml index 2758a834..8d142330 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -43,7 +43,7 @@ install: # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - - 7z e ffmpeg-7.1-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r + - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 1fea79b0..c590f12e 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -23,10 +23,10 @@ - + - + @@ -125,7 +125,7 @@ - + @@ -1642,7 +1642,7 @@ - + diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index cd35626f..c1d4ded9 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.6" +__version__ = "0.6.7" init_logger() logger = getLogger("pyscenedetect") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0801a045..6a8c090d 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,19 @@ Releases ## PySceneDetect 0.6 +### PySceneDetect 0.6.7 (August 24, 2025) + +#### Release Notes + +Minor update to fix issues with importing EDL files into DaVinci Resolve and other editors. + +#### Changelog + + - [bugfix] Fix `save-edl` end timestamp being too short by 1 frame [#516](https://github.com/Breakthrough/PySceneDetect/issues/516) + - [general] Updates to Windows distributions: + - ffmpeg 7.1 -> 8.0 + + ### PySceneDetect 0.6.6 (March 9, 2025) #### Release Notes diff --git a/website/pages/docs.md b/website/pages/docs.md index 96555cdb..346036b2 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,7 @@ ## Stable * [latest](latest/) + * [v0.6.7](0.6.7/) * [v0.6.6](0.6.6/) * [v0.6.5](0.6.5/) * [v0.6.4](0.6.4/) diff --git a/website/pages/download.md b/website/pages/download.md index 4d3b6d62..e356c800 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi ## Windows Build (64-bit Only)  
-

Latest Release: v0.6.6

-

  Release Date:  March 9, 2025

-  Installer  (recommended)      -  Portable .zip      +

Latest Release: v0.6.7

+

  Release Date:  August 24, 2025

+  Installer  (recommended)      +  Portable .zip        Getting Started
diff --git a/website/pages/index.md b/website/pages/index.md index 05d1866b..5dadd243 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
-

  Latest Release: v0.6.6 (March 9, 2025)

+

  Latest Release: v0.6.7 (August 24, 2025)

  Download        Changelog        Documentation        Getting Started
See the changelog for the latest release notes and known issues. From 886ca4ba25cb2fabe9a216ff0b675323e0952f43 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:47:24 -0400 Subject: [PATCH 004/163] [dist] Fix installer files --- appveyor.yml | 4 +++- dist/installer/PySceneDetect.aip | 9 --------- tests/test_cli.py | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 8d142330..af16f038 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -99,7 +99,9 @@ test_script: - git checkout refs/remotes/origin/resources -- tests/resources/ - move dist\scenedetect\ffmpeg.exe ffmpeg.exe # Run unit tests - - pytest + # TODO: We are at the new build time limit for this plan apparently, 10 mins. Figure out a + # strategy to deal with that (see if we can use Github as a builder?). + # - pytest # Test Windows build - move ffmpeg.exe dist\scenedetect\ffmpeg.exe - cd dist/scenedetect diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index c590f12e..4e692898 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -366,7 +366,6 @@ - @@ -639,13 +638,6 @@ - - - - - - - @@ -1897,7 +1889,6 @@ - diff --git a/tests/test_cli.py b/tests/test_cli.py index 533f6f9b..c390f139 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -104,7 +104,7 @@ def invoke_scenedetect( if config_file: command += " -c %s" % config_file command += " " + args.format(**value_dict) - return subprocess.call(command.strip().split(" "), shell=True) + return subprocess.call(command.strip().split(" ")) def test_cli_no_args(): From de5a63b96c7ce85ec15ba6214275397bd4eb8e3c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 30 Aug 2025 00:18:06 -0400 Subject: [PATCH 005/163] [scene_detector] Make detector interface an ABC and cleanup properties --- scenedetect/detector.py | 56 ++++++++++++++++++++---------------- scenedetect/scene_manager.py | 3 -- website/pages/changelog.md | 2 ++ 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 1e0b2e7c..5f903601 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -25,6 +25,7 @@ """ import typing as ty +from abc import ABC, abstractmethod from enum import Enum import numpy @@ -33,36 +34,18 @@ from scenedetect.stats_manager import StatsManager -class SceneDetector: +class SceneDetector(ABC): """Base class to inherit from when implementing a scene detection algorithm. This API is not yet stable and subject to change. """ - # TODO(v0.7): Make this a proper abstract base class. + def __init__(self): + self._stats_manager: ty.Optional[StatsManager] = None - # TODO(v0.7): This should be a property. - stats_manager: ty.Optional[StatsManager] = None - """Optional :class:`StatsManager ` to - use for caching frame metrics to and from.""" - - def stats_manager_required(self) -> bool: - """Stats Manager Required: Prototype indicating if detector requires stats. - - Returns: - True if a StatsManager is required for the detector, False otherwise. - """ - return False - - def get_metrics(self) -> ty.List[str]: - """Returns a list of all metric names/keys used by this detector. - - Returns: - List of strings of frame metric key names that will be used by - the detector when a StatsManager is passed to process_frame. - """ - return [] + # Required Methods + @abstractmethod def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray ) -> ty.List[FrameTimecode]: @@ -75,7 +58,8 @@ def process_frame( Returns: List of timecodes where scene cuts have been detected, if any. """ - return [] + + # Optional Methods def post_process(self, timecode: int) -> ty.List[FrameTimecode]: """Called after there are no more frames to process. @@ -95,6 +79,30 @@ def event_buffer_length(self) -> int: """ return 0 + # Frame Stats/Metrics + + @property + def stats_manager(self) -> ty.Optional[StatsManager]: + """Optional :class:`StatsManager ` to use for + storing frame metrics. When this detector is added to a parent + :class:`SceneManager `, then this is set to the + same :class:`StatsManager ` of the parent - but + only if it has one itself.""" + return self._stats_manager + + @stats_manager.setter + def stats_manager(self, value: ty.Optional[StatsManager]): + self._stats_manager = value + + def get_metrics(self) -> ty.List[str]: + """Returns a list of all metric names/keys used by this detector. + + Returns: + List of strings of frame metric key names that will be used by + the detector when a StatsManager is passed to process_frame. + """ + return [] + class FlashFilter: """Filters fast-cuts to enforce minimum scene length.""" diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index e94858b3..4746b288 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -313,9 +313,6 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ - if self._stats_manager is None and detector.stats_manager_required(): - assert not self._detector_list - self._stats_manager = StatsManager() detector.stats_manager = self._stats_manager if self._stats_manager is not None: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 96c8aa76..586bf7c3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -703,6 +703,8 @@ Although there have been minimal changes to most API examples, there are several * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) * Remove deprecated `AdaptiveDetector` constructor argument `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) + * Remove `SceneDetector.stats_manager_required` property as it is no longer required + * Remove `SceneDetector` is now a Python abstract class (`abc.ABC`) but all method names remain the same #### General From 727f94bd1048218e03728daf7e5d500e6bccfd23 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 30 Aug 2025 00:51:20 -0400 Subject: [PATCH 006/163] [common] Simplify FrameTimecode implementation --- scenedetect/common.py | 166 ++++++++--------------------------- tests/test_frame_timecode.py | 8 +- 2 files changed, 41 insertions(+), 133 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index a120f1d1..6d594f82 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -402,21 +402,44 @@ def _parse_timecode_string(self, input: str) -> int: raise ValueError("Timecode seconds value must be positive.") return self._seconds_to_frames(as_float) - def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> int: + """Get the frame number from `other` for arithmetic operations.""" if isinstance(other, int): - self._frame_num += other - elif isinstance(other, FrameTimecode): + return other + if isinstance(other, float): + return self._seconds_to_frames(other) + if isinstance(other, str): + return self._parse_timecode_string(other) + if isinstance(other, FrameTimecode): if self.equal_framerate(other._framerate): - self._frame_num += other._frame_num - else: - raise ValueError("FrameTimecode instances require equal framerate for addition.") - # Check if value to add is in number of seconds. - elif isinstance(other, float): - self._frame_num += self._seconds_to_frames(other) - elif isinstance(other, str): - self._frame_num += self._parse_timecode_string(other) - else: - raise TypeError("Unsupported type for performing addition with FrameTimecode.") + return other._frame_num + raise ValueError("FrameTimecode instances require equal framerate for arithmetic.") + raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") + + def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if other is None: + return False + # Allow comparison with other types by converting them to frames. + # If the framerate is not equal, a TypeError will be raised. + return self.frame_num == self._get_other_as_frames(other) + + def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return not self == other + + def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num < self._get_other_as_frames(other) + + def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num <= self._get_other_as_frames(other) + + def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num > self._get_other_as_frames(other) + + def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num >= self._get_other_as_frames(other) + + def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + self._frame_num += self._get_other_as_frames(other) if self._frame_num < 0: # Required to allow adding negative seconds/frames. self._frame_num = 0 return self @@ -427,22 +450,7 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if isinstance(other, int): - self._frame_num -= other - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): - self._frame_num -= other._frame_num - else: - raise ValueError("FrameTimecode instances require equal framerate for subtraction.") - # Check if value to add is in number of seconds. - elif isinstance(other, float): - self._frame_num -= self._seconds_to_frames(other) - elif isinstance(other, str): - self._frame_num -= self._parse_timecode_string(other) - else: - raise TypeError( - "Unsupported type for performing subtraction with FrameTimecode: %s" % type(other) - ) + self._frame_num -= self._get_other_as_frames(other) if self._frame_num < 0: self._frame_num = 0 return self @@ -452,106 +460,6 @@ def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi to_return -= other return to_return - def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if isinstance(other, int): - return self._frame_num == other - elif isinstance(other, float): - return self.seconds == other - elif isinstance(other, str): - return self._frame_num == self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): - return self._frame_num == other._frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - elif other is None: - return False - else: - raise TypeError( - "Unsupported type for performing == with FrameTimecode: %s" % type(other) - ) - - def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - return not self == other - - def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num < other - elif isinstance(other, float): - return self.seconds < other - elif isinstance(other, str): - return self._frame_num < self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): - return self._frame_num < other._frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing < with FrameTimecode: %s" % type(other) - ) - - def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num <= other - elif isinstance(other, float): - return self.seconds <= other - elif isinstance(other, str): - return self._frame_num <= self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): - return self._frame_num <= other._frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing <= with FrameTimecode: %s" % type(other) - ) - - def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num > other - elif isinstance(other, float): - return self.seconds > other - elif isinstance(other, str): - return self._frame_num > self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): - return self._frame_num > other._frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing > with FrameTimecode: %s" % type(other) - ) - - def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num >= other - elif isinstance(other, float): - return self.seconds >= other - elif isinstance(other, str): - return self._frame_num >= self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): - return self._frame_num >= other._frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing >= with FrameTimecode: %s" % type(other) - ) - # TODO(v1.0): __int__ and __float__ should be removed. Mark as deprecated, and indicate # need to use relevant property instead. diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 83eb0477..a1081d45 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -200,9 +200,9 @@ def test_equality(): assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) # Comparing FrameTimecodes with different framerates raises a TypeError. - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert x == FrameTimecode(timecode=1.0, fps=100.0) - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert x == FrameTimecode(timecode=1.0, fps=10.1) assert x == FrameTimecode(x) @@ -249,7 +249,7 @@ def test_addition(): assert x + 10 == "00:00:02.000" - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert FrameTimecode("00:00:02.000", fps=20.0) == x + 10 @@ -268,7 +268,7 @@ def test_subtraction(): assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert FrameTimecode("00:00:02.000", fps=20.0) == x - 10 From ef1f39b72a1bc95b8baf35efa0a76ca7d65323c7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 31 Aug 2025 23:13:41 -0400 Subject: [PATCH 007/163] [common] Allow PTS mode to pass scanning loop Most commands still fail due to missing arithmetic operators, but a quick spot check by overriding or skipping a few let me run `split-video` successfully. That will be fixed in a follow up however. --- scenedetect/common.py | 67 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 6d594f82..2fa9bb4e 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -131,7 +131,7 @@ class Interpolation(Enum): # # We might be able to avoid changing the detector interface if we just have them work directly with # PTS and convert them back to FrameTimecodes with the same time base. -@dataclass +@dataclass(frozen=True) class Timecode: """Timing information associated with a given frame.""" @@ -181,6 +181,7 @@ def __init__( if isinstance(timecode, FrameTimecode): self._framerate = timecode._framerate if fps is None else fps self._frame_num = timecode._frame_num + self._timecode = timecode._timecode return # Timecode. @@ -411,35 +412,60 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] if isinstance(other, str): return self._parse_timecode_string(other) if isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): + # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. + if self._framerate and other._framerate and not self.equal_framerate(other._framerate): + raise ValueError( + "FrameTimecode instances require equal framerate for frame-based arithmetic." + ) + if other._frame_num is not None: return other._frame_num - raise ValueError("FrameTimecode instances require equal framerate for arithmetic.") + # If other has no frame_num, it must have a timecode. Convert to frames. + return self._seconds_to_frames(other.seconds) raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False - # Allow comparison with other types by converting them to frames. - # If the framerate is not equal, a TypeError will be raised. + if self._timecode: + return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - return not self == other + if other is None: + return True + if self._timecode: + return self.seconds != self._get_other_as_seconds(other) + return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - self._frame_num += self._get_other_as_frames(other) + if self._timecode: + new_seconds = self.seconds + self._get_other_as_seconds(other) + # TODO: This is incorrect for VFR, need a better way to handle this. + # For now, we convert back to a frame number. + self._frame_num = self._seconds_to_frames(new_seconds) + self._timecode = None + else: + self._frame_num += self._get_other_as_frames(other) if self._frame_num < 0: # Required to allow adding negative seconds/frames. self._frame_num = 0 return self @@ -450,7 +476,14 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - self._frame_num -= self._get_other_as_frames(other) + if self._timecode: + new_seconds = self.seconds - self._get_other_as_seconds(other) + # TODO: This is incorrect for VFR, need a better way to handle this. + # For now, we convert back to a frame number. + self._frame_num = self._seconds_to_frames(new_seconds) + self._timecode = None + else: + self._frame_num -= self._get_other_as_frames(other) if self._frame_num < 0: self._frame_num = 0 return self @@ -473,7 +506,25 @@ def __str__(self) -> str: return self.get_timecode() def __repr__(self) -> str: + if self._timecode: + return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self._frame_num, self._framerate) def __hash__(self) -> int: + if self._timecode: + return hash(self._timecode) return self._frame_num + + def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: + """Get the time in seconds from `other` for arithmetic operations.""" + if isinstance(other, int): + return float(other) / self._framerate + if isinstance(other, float): + return other + if isinstance(other, str): + # This is not ideal, but we need a framerate to parse strings. + # We create a temporary FrameTimecode to do this. + return FrameTimecode(timecode=other, fps=self._framerate).seconds + if isinstance(other, FrameTimecode): + return other.seconds + raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") From ad9d35dd94f8daa2d46a69c484b01d469040c826 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 2 Sep 2025 21:26:10 -0400 Subject: [PATCH 008/163] [tests] Rename test_frame_timecode -> test_timecode --- tests/{test_frame_timecode.py => test_timecode.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_frame_timecode.py => test_timecode.py} (100%) diff --git a/tests/test_frame_timecode.py b/tests/test_timecode.py similarity index 100% rename from tests/test_frame_timecode.py rename to tests/test_timecode.py From adbaad43ebc65d677cf63e5ec53d188a4596d7f5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Sep 2025 22:29:19 -0400 Subject: [PATCH 009/163] [site] Allow URLs of the form scenedetect.com/issue/200 --- website/mkdocs.yml | 3 +++ website/pages/issue.md | 4 ++++ website/pages/issues.md | 4 ++++ website/pages/redirect.js | 22 ++++++++++++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 website/pages/issue.md create mode 100644 website/pages/issues.md create mode 100644 website/pages/redirect.js diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 501fc51e..a7c87a77 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -39,3 +39,6 @@ markdown_extensions: [fenced_code] extra_css: - style.css + +extra_javascript: + - redirect.js diff --git a/website/pages/issue.md b/website/pages/issue.md new file mode 100644 index 00000000..b9393d82 --- /dev/null +++ b/website/pages/issue.md @@ -0,0 +1,4 @@ + +# Going to Github Issues... + +Redirecting to Github Issues. diff --git a/website/pages/issues.md b/website/pages/issues.md new file mode 100644 index 00000000..b9393d82 --- /dev/null +++ b/website/pages/issues.md @@ -0,0 +1,4 @@ + +# Going to Github Issues... + +Redirecting to Github Issues. diff --git a/website/pages/redirect.js b/website/pages/redirect.js new file mode 100644 index 00000000..832f1f53 --- /dev/null +++ b/website/pages/redirect.js @@ -0,0 +1,22 @@ + +(function() { + var path = window.location.pathname; + + const TARGETS = [ 'issue', 'issues' ] + for (const target of TARGETS) { + if (path == target) { + window.location.href = 'https://github.com/Breakthrough/PySceneDetect/issues/'; + } + } + + const PREFIXES = ['/issue/', '/issues/'] + for (const prefix of PREFIXES) { + if (path.startsWith(prefix)) { + var issueNumber = path.substring(prefix.length); + if (issueNumber) { + var newUrl = 'https://github.com/Breakthrough/PySceneDetect/issues/' + issueNumber; + window.location.href = newUrl; + } + } + } +})(); From 8f94e0474b1437c9ba9546e907d59ad47a6d2666 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Sep 2025 22:32:40 -0400 Subject: [PATCH 010/163] [dist] Exclude click 8.3.0 as per #521 --- requirements.txt | 3 ++- requirements_headless.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2c45e1a5..fd5524ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python platformdirs diff --git a/requirements_headless.txt b/requirements_headless.txt index 4dfedd38..f32dc452 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements for Headless Machines # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python-headless platformdirs From b83789ba32e2cb1a927778d4a0e2712360dea080 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Sep 2025 20:04:27 -0400 Subject: [PATCH 011/163] [site] Fix custom redirects for issue tracker #522 --- website/mkdocs.yml | 3 --- website/overrides/404.html | 40 ++++++++++++++++++++++++++++++++++++++ website/pages/issue.md | 4 ---- website/pages/issues.md | 4 ---- website/pages/redirect.js | 22 --------------------- 5 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 website/overrides/404.html delete mode 100644 website/pages/issue.md delete mode 100644 website/pages/issues.md delete mode 100644 website/pages/redirect.js diff --git a/website/mkdocs.yml b/website/mkdocs.yml index a7c87a77..501fc51e 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -39,6 +39,3 @@ markdown_extensions: [fenced_code] extra_css: - style.css - -extra_javascript: - - redirect.js diff --git a/website/overrides/404.html b/website/overrides/404.html new file mode 100644 index 00000000..72994aec --- /dev/null +++ b/website/overrides/404.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block content %} +

Page Not Found

+

This page does not exist.

+ + +{% endblock %} \ No newline at end of file diff --git a/website/pages/issue.md b/website/pages/issue.md deleted file mode 100644 index b9393d82..00000000 --- a/website/pages/issue.md +++ /dev/null @@ -1,4 +0,0 @@ - -# Going to Github Issues... - -Redirecting to Github Issues. diff --git a/website/pages/issues.md b/website/pages/issues.md deleted file mode 100644 index b9393d82..00000000 --- a/website/pages/issues.md +++ /dev/null @@ -1,4 +0,0 @@ - -# Going to Github Issues... - -Redirecting to Github Issues. diff --git a/website/pages/redirect.js b/website/pages/redirect.js deleted file mode 100644 index 832f1f53..00000000 --- a/website/pages/redirect.js +++ /dev/null @@ -1,22 +0,0 @@ - -(function() { - var path = window.location.pathname; - - const TARGETS = [ 'issue', 'issues' ] - for (const target of TARGETS) { - if (path == target) { - window.location.href = 'https://github.com/Breakthrough/PySceneDetect/issues/'; - } - } - - const PREFIXES = ['/issue/', '/issues/'] - for (const prefix of PREFIXES) { - if (path.startsWith(prefix)) { - var issueNumber = path.substring(prefix.length); - if (issueNumber) { - var newUrl = 'https://github.com/Breakthrough/PySceneDetect/issues/' + issueNumber; - window.location.href = newUrl; - } - } - } -})(); From 3632ecabc1612e825a43edc354c5286bbd5928f1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Sep 2025 20:13:03 -0400 Subject: [PATCH 012/163] [project] Update TODO style to be clickable --- scenedetect/_cli/commands.py | 2 +- scenedetect/_cli/controller.py | 2 +- scenedetect/backends/moviepy.py | 9 +++++---- scenedetect/backends/opencv.py | 6 +++--- scenedetect/backends/pyav.py | 3 ++- scenedetect/detectors/adaptive_detector.py | 2 -- scenedetect/scene_manager.py | 6 +++--- scenedetect/stats_manager.py | 4 ++-- tests/test_cli.py | 3 ++- tests/test_detectors.py | 8 ++++---- website/overrides/404.html | 8 +++++--- 11 files changed, 28 insertions(+), 25 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 82f66386..6813caa8 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -504,7 +504,7 @@ def save_otio( frame_rate = context.video_stream.frame_rate # List of track mapping to resource type. - # TODO(#497): Allow exporting without an audio track. + # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. track_list = {"Video 1": "Video"} if audio: track_list["Audio 1"] = "Audio" diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 48ff340d..eee05267 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -120,7 +120,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: ) # Handle case where video failure is most likely due to multiple audio tracks (#179). - # TODO(#380): Ensure this does not erroneusly fire. + # TODO(https://scenedetect.com/issues/380): Ensure this does not erroneusly fire. if num_frames <= 0 and isinstance(context.video_stream, VideoStreamCv2): logger.critical( "Failed to read any frames from video file. This could be caused by the video" diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 96c38a04..c4f4e8a5 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -185,10 +185,11 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ) success = True except OSError as ex: - # TODO(#380): Other backends do not currently throw an exception if attempting to seek - # past EOF. We need to ensure consistency for seeking past end of video with respect to - # errors and behaviour, and should probably gracefully stop at the last frame instead - # of throwing an exception. + # TODO(https://scenedetect.com/issues/380): Other backends do not currently throw an + # exception if attempting to seek past EOF. + # + # We need to ensure consistency for seeking past end of video with respect to errors and + # behaviour, and should probably gracefully stop at the last frame instead of throwing. if target >= self.duration: raise SeekError("Target frame is beyond end of video!") from ex raise diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index c10deca3..f8d402f9 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -324,9 +324,9 @@ def _open_capture(self, framerate: ty.Optional[float] = None): cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 -# TODO(#168): Support non-monotonic timing for `position`. VFR timecode support is a -# prerequisite for this. Timecodes are currently calculated by multiplying the framerate -# by number of frames. Actual elapsed time can be obtained via `position_ms` for now. +# TODO(https://scenedetect.com/issues/168): Support non-monotonic timing for `position`. VFR timecode +# support is a prerequisite for this. Timecodes are currently calculated by multiplying the +# framerate by number of frames. Actual elapsed time can be obtained via `position_ms` for now. class VideoCaptureAdapter(VideoStream): """Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports VideoCaptures which may not support seeking. diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 32f57ead..c78183ec 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -70,7 +70,8 @@ def __init__( """ self._container = None - # TODO(#258): See what self._container.discard_corrupt = True does with corrupt videos. + # TODO(https://scenedetect.com/issues/258): See what `self._container.discard_corrupt = True` + # does with corrupt videos. super().__init__() # Ensure specified framerate is valid if set. diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index fa23795f..7a0a23af 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -100,8 +100,6 @@ def get_metrics(self) -> ty.List[str]: def process_frame( self, timecode: FrameTimecode, frame_img: np.ndarray ) -> ty.List[FrameTimecode]: - # TODO(#283): Merge this with ContentDetector and turn it on by default. - super().process_frame(timecode=timecode, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 4746b288..7bb45d38 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -387,9 +387,9 @@ def _process_frame( """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" new_cuts = False - # TODO(#283): This breaks with AdaptiveDetector as cuts differ from the frame number - # being processed. Allow detectors to specify the max frame lookahead they require - # (i.e. any event will never be more than N frames behind the current one). + # TODO(https://scenedetect.com/issues/283): This breaks with AdaptiveDetector as cuts differ + # from the frame number being processed. Allow detectors to specify the max frame lookahead + # they require (i.e. any event will never be more than N frames behind the current one). self._frame_buffer.append(frame_im) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index e6b873ab..61e67970 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -118,8 +118,8 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: """Register a list of metric keys that will be used by the detector.""" self._metric_keys = self._metric_keys.union(set(metric_keys)) - # TODO(#507): This interface is difficult to use, we should support the dictionary protocol. - # Ideally this should work with Panadas. This could be done with the v0.7 API change. + # TODO(https://scenedetect.com/issues/507): We should support the dictionary protocol instead + # of using this bespoke interface. It would be useful for Pandas compatibility as well. def get_metrics( self, timecode: FrameTimecode, metric_keys: ty.Iterable[str] ) -> ty.List[ty.Any]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4be5574d..61022bf8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -520,7 +520,8 @@ def test_cli_save_images_path_handling(tmp_path: Path): assert image.shape == (544, 1280, 3) -# TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. +# TODO(https://scenedetect.com/issues/134): This works fine with OpenCV currently, but needs to be +# supported for PyAV and MoviePy. def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): """Test that `save-images` command rotates images correctly with the default backend.""" assert ( diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 43ebfd23..0e5f4214 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -41,10 +41,10 @@ ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) -# TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores -# regardless of resolution. This will ensure that threshold values will hold true for different -# input sources. Most detectors already provide this guarantee, so this is more to prevent any -# regressions in the future. +# TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively +# consistent frame scores regardless of resolution. This will ensure that threshold values will hold +# true for different input sources. Most detectors already provide this guarantee, so this is more +# to prevent any regressions in the future. # TODO: Reduce code duplication here and in `conftest.py` diff --git a/website/overrides/404.html b/website/overrides/404.html index 72994aec..7b608acb 100644 --- a/website/overrides/404.html +++ b/website/overrides/404.html @@ -12,12 +12,14 @@

Page Not Found

var pageTitle = document.getElementById('404-title'); var pageBody = document.getElementById('404-body'); + // The canonical form is "issues" but we allow "issue" as well. const TARGETS = [ '/issue', '/issues', '/issue/', '/issues/' ]; for (const target of TARGETS) { if (path === target) { pageTitle.innerText = 'Redirecting...'; - pageBody.innerText = 'Redirecting to GitHub issues...'; - window.location.href = 'https://github.com/Breakthrough/PySceneDetect/issues/'; + const url = 'https://github.com/Breakthrough/PySceneDetect/issues/'; + pageBody.innerHTML = 'Redirecting to GitHub issues...'; + window.location.href = url; return; } } @@ -28,8 +30,8 @@

Page Not Found

var issueNumber = path.substring(prefix.length); if (issueNumber) { pageTitle.innerText = 'Redirecting...'; - pageBody.innerText = 'Redirecting to issue #' + issueNumber + ' on GitHub...'; var newUrl = 'https://github.com/Breakthrough/PySceneDetect/issues/' + issueNumber; + pageBody.innerHTML = 'Redirecting to issue #' + issueNumber + ' on GitHub...'; window.location.href = newUrl; return; } From a87ea690951fab7d34d412ef1b8216c47fa078a4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 20:03:26 -0400 Subject: [PATCH 013/163] [dist] Add new workflow to publish packages to PyPI --- .github/workflows/publish-pypi.yml | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..9ccdbf2e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,106 @@ +name: Publish PyPI Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish' + required: true + environment: + description: 'PyPI environment' + required: true + type: choice + options: + - test + - prod + default: 'test' + +jobs: + verify-builds: + name: Verify build workflows passed + runs-on: ubuntu-latest + steps: + - name: Verify build workflows + uses: actions/github-script@v6 + with: + script: | + const { owner, repo } = context.repo; + const tag = "${{ github.event.inputs.tag }}"; + const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; + let workflowConclusions = {}; + + console.log(`Checking for successful workflow runs for tag: ${tag}`); + + const { data: response } = await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + event: 'push', + }); + + const runsForTag = response.workflow_runs.filter(run => run.head_branch === tag); + + for (const run of runsForTag) { + if (requiredWorkflows.includes(run.name)) { + if (!workflowConclusions[run.name] || new Date(run.created_at) > new Date(workflowConclusions[run.name].created_at)) { + workflowConclusions[run.name] = { + conclusion: run.conclusion, + created_at: run.created_at, + html_url: run.html_url, + }; + } + } + } + + let allSuccess = true; + for (const workflowName of requiredWorkflows) { + if (!workflowConclusions[workflowName]) { + core.setFailed(`Workflow "${workflowName}" was not found for tag ${tag}.`); + allSuccess = false; + } else if (workflowConclusions[workflowName].conclusion !== 'success') { + core.setFailed(`Workflow "${workflowName}" did not succeed for tag ${tag}. Conclusion was "${workflowConclusions[workflowName].conclusion}". See: ${workflowConclusions[workflowName].html_url}`); + allSuccess = false; + } else { + console.log(`✅ Workflow "${workflowName}" succeeded for tag ${tag}.`); + } + } + + if (!allSuccess) { + throw new Error("One or more required build workflows did not succeed."); + } + + build-and-publish: + name: Build and publish Python distributions to ${{ github.event.inputs.environment }} PyPI + runs-on: ubuntu-latest + needs: verify-builds + + + environment: + name: ${{ github.event.inputs.environment == 'test' && 'testpypi' || 'pypi' }} + url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} From 4b21c2783547be0f2f1fd77a843d6f930629ad5c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Sep 2025 22:32:40 -0400 Subject: [PATCH 014/163] [dist] Exclude click 8.3.0 as per #521 --- requirements.txt | 3 ++- requirements_headless.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2c45e1a5..fd5524ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python platformdirs diff --git a/requirements_headless.txt b/requirements_headless.txt index 4dfedd38..f32dc452 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements for Headless Machines # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python-headless platformdirs From 7adac99f640e2d52e4aa7714323eb2ba5f125b85 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 20:03:26 -0400 Subject: [PATCH 015/163] [dist] Add new workflow to publish packages to PyPI --- .github/workflows/publish-pypi.yml | 120 +++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..670468d6 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,120 @@ +name: Publish PyPI Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish' + required: true + environment: + description: 'PyPI environment' + required: true + type: choice + options: + - test + - prod + default: 'test' + +jobs: + verify-builds: + name: Verify build workflows passed + runs-on: ubuntu-latest + steps: + - name: Verify build workflows + uses: actions/github-script@v6 + with: + script: | + const { owner, repo } = context.repo; + const tag = "${{ github.event.inputs.tag }}"; + const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; + let workflowConclusions = {}; + + console.log(`Checking for successful workflow runs for tag: ${tag}`); + + const { data: response } = await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + event: 'push', + }); + + const runsForTag = response.workflow_runs.filter(run => run.head_branch === tag); + + for (const run of runsForTag) { + if (requiredWorkflows.includes(run.name)) { + if (!workflowConclusions[run.name] || new Date(run.created_at) > new Date(workflowConclusions[run.name].created_at)) { + workflowConclusions[run.name] = { + conclusion: run.conclusion, + created_at: run.created_at, + html_url: run.html_url, + }; + } + } + } + + let allSuccess = true; + for (const workflowName of requiredWorkflows) { + if (!workflowConclusions[workflowName]) { + core.setFailed(`Workflow "${workflowName}" was not found for tag ${tag}.`); + allSuccess = false; + } else if (workflowConclusions[workflowName].conclusion !== 'success') { + core.setFailed(`Workflow "${workflowName}" did not succeed for tag ${tag}. Conclusion was "${workflowConclusions[workflowName].conclusion}". See: ${workflowConclusions[workflowName].html_url}`); + allSuccess = false; + } else { + console.log(`✅ Workflow "${workflowName}" succeeded for tag ${tag}.`); + } + } + + if (!allSuccess) { + throw new Error("One or more required build workflows did not succeed."); + } + + build-and-publish: + name: Build and publish Python distributions to ${{ github.event.inputs.environment }} PyPI + runs-on: ubuntu-latest + needs: verify-builds + + + environment: + name: ${{ github.event.inputs.environment == 'test' && 'testpypi' || 'pypi' }} + url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build Package + run: | + python -m build + mkdir pkg + mv dist/*.tar.gz pkg/ + mv dist/*.whl pkg/ + + - name: Upload Package + uses: actions/upload-artifact@v4 + with: + name: scenedetect-dist + path: | + pkg/*.tar.gz + pkg/*.whl + + - name: Publish Package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} + packages-dir: pkg/ + print-hash: true From f8e1914f9057a2d9692738ca0fece14fc4b2ecad Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 20:06:20 -0400 Subject: [PATCH 016/163] [dist] Prepare 0.6.7.1 --- .github/workflows/build.yml | 3 +-- requirements.txt | 2 +- requirements_headless.txt | 2 +- scenedetect/__init__.py | 2 +- setup.cfg | 8 ++++---- website/pages/changelog.md | 4 ++++ 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6807a714..52ef6876 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,8 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # TODO: Bump ubuntu 20 to 22 when past EOL date. - os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] + os: [macos-13, macos-14, ubuntu-22.04, ubuntu-latest, windows-latest] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. diff --git a/requirements.txt b/requirements.txt index fd5524ff..bb9dd91d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # av>=9.2 # click 8.3.0 is excluded as per https://scenedetect.com/issues/521 -click~=8.0, !=8.3.0 +click~=8.0,<8.3.0 numpy opencv-python platformdirs diff --git a/requirements_headless.txt b/requirements_headless.txt index f32dc452..b9050ffa 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -3,7 +3,7 @@ # av>=9.2 # click 8.3.0 is excluded as per https://scenedetect.com/issues/521 -click~=8.0, !=8.3.0 +click~=8.0,<8.3.0 numpy opencv-python-headless platformdirs diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index c1d4ded9..264d2940 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.7" +__version__ = "0.6.7.1" init_logger() logger = getLogger("pyscenedetect") diff --git a/setup.cfg b/setup.cfg index e35c5861..206be961 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ [metadata] name = scenedetect version = attr: scenedetect.__version__ -license = BSD 3-Clause License +license = BSD-3-Clause author = Brandon Castellano author_email = brandon248@gmail.com description = Video scene cut/shot detection program and Python library. @@ -21,7 +21,6 @@ classifiers = Intended Audience :: Developers Intended Audience :: End Users/Desktop Intended Audience :: System Administrators - License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 @@ -39,7 +38,8 @@ keywords = video computer-vision analysis [options] install_requires = - Click + # click <8.3.0 is excluded as per https://scenedetect.com/issues/521. + click~=8.0,<8.3.0 numpy platformdirs tqdm @@ -54,7 +54,7 @@ python_requires = >=3.7 [options.extras_require] opencv = opencv-python opencv-headless = opencv-python-headless -pyav = av +pyav = av>=9.2 moviepy = moviepy [options.entry_points] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6a8c090d..3207580e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,10 @@ Releases ## PySceneDetect 0.6 +### PySceneDetect 0.6.7.1 (September 24, 2025) + +Re-release of the Python package that fixes dependency version pinning. + ### PySceneDetect 0.6.7 (August 24, 2025) #### Release Notes From 287b54864efe1250a970bc24fa15684a7f46b5a2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 23:14:19 -0400 Subject: [PATCH 017/163] [dist] Update environment names for publish workflow --- .github/workflows/publish-pypi.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 670468d6..fcc75863 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -4,23 +4,23 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to publish' + description: 'Tag To Publish' required: true environment: - description: 'PyPI environment' + description: 'PyPI Environment' required: true type: choice options: - test - - prod + - release default: 'test' jobs: - verify-builds: - name: Verify build workflows passed + verify: + name: Verify Build runs-on: ubuntu-latest steps: - - name: Verify build workflows + - name: Check workflows uses: actions/github-script@v6 with: script: | @@ -68,21 +68,20 @@ jobs: throw new Error("One or more required build workflows did not succeed."); } - build-and-publish: - name: Build and publish Python distributions to ${{ github.event.inputs.environment }} PyPI + publish: + name: Building and Publishing to ${{ github.event.inputs.environment }} PyPI runs-on: ubuntu-latest - needs: verify-builds - + needs: verify environment: - name: ${{ github.event.inputs.environment == 'test' && 'testpypi' || 'pypi' }} + name: ${{ github.event.inputs.environment == 'test' && 'test' || 'release' }} url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - - name: Checkout code + - name: Checkout ${{ github.event.inputs.tag }} uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.tag }} From 355fe1ab24695fe2ac48e9f04a72be2f0a2cf1ad Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 28 Sep 2025 22:04:36 -0400 Subject: [PATCH 018/163] [api] Add backwards compat. helpers to avoid breaking imports --- scenedetect/frame_timecode.py | 22 ++++++++++++++++++++++ scenedetect/scene_detector.py | 22 ++++++++++++++++++++++ scenedetect/scene_manager.py | 3 +++ scenedetect/video_splitter.py | 22 ++++++++++++++++++++++ tests/test_detectors.py | 9 +++++++++ tests/test_output.py | 9 +++++++++ tests/test_timecode.py | 9 +++++++++ website/pages/changelog.md | 20 +++++++++----------- 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 scenedetect/frame_timecode.py create mode 100644 scenedetect/scene_detector.py create mode 100644 scenedetect/video_splitter.py diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py new file mode 100644 index 00000000..8411cef0 --- /dev/null +++ b/scenedetect/frame_timecode.py @@ -0,0 +1,22 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""DEPRECATED""" + +import warnings + +warnings.warn( + "The `frame_timecode` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) + +from scenedetect.common import * # noqa: E402, F403 diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py new file mode 100644 index 00000000..5de13977 --- /dev/null +++ b/scenedetect/scene_detector.py @@ -0,0 +1,22 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""DEPRECATED""" + +import warnings + +warnings.warn( + "The `scene_detector` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) + +from scenedetect.detector import * # noqa: E402, F403 diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 7bb45d38..890d9fb1 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -95,6 +95,9 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SceneList, ) from scenedetect.detector import SceneDetector + +# TODO(v0.8): Remove the import * below, for backwards compatibility with v0.6 only. +from scenedetect.output import * # noqa: F403 from scenedetect.platform import tqdm from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py new file mode 100644 index 00000000..2b8499da --- /dev/null +++ b/scenedetect/video_splitter.py @@ -0,0 +1,22 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""DEPRECATED""" + +import warnings + +warnings.warn( + "The `video_splitter` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) + +from scenedetect.output.video import * # noqa: E402, F403 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 0e5f4214..445112ee 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -224,3 +224,12 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len + + +# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. +def test_deprecated_detector_module_emits_warning_on_import(): + SCENE_DETECTOR_WARNING = ( + "The `scene_detector` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): + from scenedetect.scene_detector import SceneDetector as _ diff --git a/tests/test_output.py b/tests/test_output.py index db3f2307..bc1762e5 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -191,3 +191,12 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): total_images += 1 assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +# TODO(v0.8): Remove this test during the removal of `scenedetect.video_splitter`. +def test_deprecated_output_modules_emits_warning_on_import(): + VIDEO_SPLITTER_WARNING = ( + "The `video_splitter` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): + from scenedetect.video_splitter import split_video_ffmpeg as _ diff --git a/tests/test_timecode.py b/tests/test_timecode.py index a1081d45..20fba42b 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -300,3 +300,12 @@ def test_precision(): assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + + +# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. +def test_deprecated_timecode_module_emits_warning_on_import(): + FRAME_TIMECODE_WARNING = ( + "The `frame_timecode` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): + from scenedetect.frame_timecode import FrameTimecode as _ diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 880765b6..6f5f96d3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -685,30 +685,28 @@ Although there have been minimal changes to most API examples, there are several #### Breaking -> Note: Imports that break when upgrading to 0.7 can usually be resolved by importing from `scenedetect` directly, rather than a submodule. The package structure has changed significantly in 0.7. - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` * Move existing functionality to new submodules: - * `scenedetect.scene_detector` moved to `scenedetect.detector` - * `scenedetect.frame_timecode` moved to `scenedetect.common` - * Output functionality from `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + * Detector interface in `scenedetect.scene_detector` moved to `scenedetect.detector` + * Timecode types in `scenedetect.frame_timecode` moved to `scenedetect.common` + * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead * `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them * Remove `FrameTimecode.previous_frame()` method - * Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation + * Remove `SceneDetector.is_processing_required()` method * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called * Remove deprecated `SparseSceneDetector` interface * Remove deprecated `SceneManager.get_event_list()` method - * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) - * Remove deprecated `AdaptiveDetector` constructor argument `min_delta_hsv` (use `min_content_val` instead) - * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) - * Remove `SceneDetector.stats_manager_required` property as it is no longer required - * Remove `SceneDetector` is now a Python abstract class (`abc.ABC`) but all method names remain the same + * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) + * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) + * Remove `advance` parameter from `VideoStream.read()` + * Remove `SceneDetector.stats_manager_required` property, no longer required + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) #### General From 684e4b45ab26cf159d1010b9333fdab2a1bc5a16 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 29 Sep 2025 22:57:00 -0400 Subject: [PATCH 019/163] [cli] Add minimum code to make CLI work with VFR videos with some commands (e.g. list-scenes) #168 Minimum scene length must still be zero for now. --- scenedetect/backends/moviepy.py | 6 +- scenedetect/backends/opencv.py | 8 +- scenedetect/backends/pyav.py | 5 + scenedetect/common.py | 159 +++++++++++++------- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/threshold_detector.py | 2 +- 6 files changed, 126 insertions(+), 56 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index c4f4e8a5..0de86013 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -24,7 +24,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import FrameTimecode +from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream @@ -173,6 +173,10 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ValueError: `target` is not a valid value (i.e. it is negative). """ success = False + if _USE_PTS_IN_DEVELOPMENT: + # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. + raise NotImplementedError() + if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index f8d402f9..44696cef 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -226,6 +226,10 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if target < 0: raise ValueError("Target seek position cannot be negative!") + if _USE_PTS_IN_DEVELOPMENT: + # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. + raise NotImplementedError() + # Have to seek one behind and call grab() after to that the VideoCapture # returns a valid timestamp when using CAP_PROP_POS_MSEC. target_frame_cv2 = (self.base_timecode + target).frame_num @@ -429,8 +433,8 @@ def frame_size(self) -> ty.Tuple[int, int]: @property def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - # TODO(v0.7): This will be incorrect for VFR. See if there is another property we can use - # to estimate the video length correctly. + # TODO(https://scenedetect.com/issue/168): This will be incorrect for VFR. See if there is + # another property we can use to estimate the video length correctly. frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: return self.base_timecode + frame_count diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index c78183ec..35e7e90f 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -249,6 +249,11 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: if target < 0: raise ValueError("Target cannot be negative!") beginning = target == 0 + + if _USE_PTS_IN_DEVELOPMENT: + # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. + raise NotImplementedError() + target = self.base_timecode + target if target >= 1: target = target - 1 diff --git a/scenedetect/common.py b/scenedetect/common.py index 2fa9bb4e..e3b8cb1d 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -70,6 +70,8 @@ import cv2 +# TODO(https://scenedetect.com/issue/168): Ensure both CFR and VFR videos work as intended with this +# flag enabled. When this feature is stable, we can then work on a roll-out plan. _USE_PTS_IN_DEVELOPMENT = False ## @@ -176,12 +178,14 @@ def __init__( self._framerate = fps self._frame_num = None self._timecode: ty.Optional[Timecode] = None + self._seconds: ty.Optional[float] = None # Copy constructor. if isinstance(timecode, FrameTimecode): self._framerate = timecode._framerate if fps is None else fps self._frame_num = timecode._frame_num self._timecode = timecode._timecode + self._seconds = timecode._seconds return # Timecode. @@ -205,21 +209,27 @@ def __init__( self._framerate = float(fps) # Process the timecode value, storing it as an exact number of frames. if isinstance(timecode, str): - # TODO(v0.7): This will be incorrect for VFR videos. Need to represent this format - # differently so we can support start/end times and min_scene_len correctly. - self._frame_num = self._parse_timecode_string(timecode) + self._seconds = self._timecode_to_seconds(timecode) + self._frame_num = self._seconds_to_frames(self._timecode_to_seconds(timecode)) else: self._frame_num = self._parse_timecode_number(timecode) - # TODO(v0.7): Add a PTS property as well and slowly transition over to that, since we don't - # always know the position as a "frame number". However, for the reverse case, we CAN state - # the presentation time if we know the frame number (for a fixed framerate video). @property def frame_num(self) -> ty.Optional[int]: + if self._timecode: + warnings.warn( + message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", + stacklevel=2, + category=UserWarning, + ) + # We can calculate the approx. # of frames by taking the presentation time and the + # time base itself. + (num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio() + return num / den return self._frame_num @property - def framerate(self) -> ty.Optional[int]: + def framerate(self) -> ty.Optional[float]: return self._framerate def get_frames(self) -> int: @@ -250,7 +260,7 @@ def get_framerate(self) -> float: ) return self.framerate - # TODO(v0.7): Figure out how to deal with VFR here. + # TODO(https://scenedetect.com/issue/168): Figure out how to deal with VFR here. def equal_framerate(self, fps) -> bool: """Equal Framerate: Determines if the passed framerate is equal to that of this object. @@ -261,7 +271,8 @@ def equal_framerate(self, fps) -> bool: bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. """ - # TODO(v0.7): Support this comparison in the case FPS is not set but a timecode is. + # TODO(https://scenedetect.com/issue/168): Support this comparison in the case FPS is not + # set but a timecode is. return math.fabs(self.framerate - fps) < MAX_FPS_DELTA @property @@ -269,6 +280,8 @@ def seconds(self) -> float: """The frame's position in number of seconds.""" if self._timecode: return self._timecode.seconds + if self._seconds: + return self._seconds # Assume constant framerate if we don't have timing information. return float(self._frame_num) / self._framerate @@ -355,14 +368,13 @@ def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: else: raise TypeError("Timecode format/type unrecognized.") - def _parse_timecode_string(self, input: str) -> int: - """Parses a string based on the three possible forms (in timecode format, - as an integer number of frames, or floating-point seconds, ending with 's'). - - Requires that the `framerate` property is set before calling this method. - Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', - '9000', '300s', and '300.0' are all possible valid values, all representing - a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). + def _timecode_to_seconds(self, input: str) -> float: + """Parses a string based on the three possible forms (in timecode format, as an integer + number of frames, or floating-point seconds, ending with 's'). Exact frame numbers (int) + requires the `framerate` property was set when the timecode was created. Assuming a + framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', '9000', '300s', and + '300.0' are all possible valid values. These values represent periods of time equal to + 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). Raises: ValueError: Value could not be parsed correctly. @@ -374,7 +386,7 @@ def _parse_timecode_string(self, input: str) -> int: timecode = int(input) if timecode < 0: raise ValueError("Timecode frame number must be positive.") - return timecode + return timecode * self.framerate # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") @@ -392,7 +404,7 @@ def _parse_timecode_string(self, input: str) -> int: if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60): raise ValueError("Invalid timecode range (values outside allowed range).") secs += (hrs * 60 * 60) + (mins * 60) - return self._seconds_to_frames(secs) + return secs # Try to parse the number as seconds in the format 1234.5 or 1234s if input.endswith("s"): input = input[:-1] @@ -401,7 +413,7 @@ def _parse_timecode_string(self, input: str) -> int: as_float = float(input) if as_float < 0.0: raise ValueError("Timecode seconds value must be positive.") - return self._seconds_to_frames(as_float) + return as_float def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> int: """Get the frame number from `other` for arithmetic operations.""" @@ -410,7 +422,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] if isinstance(other, float): return self._seconds_to_frames(other) if isinstance(other, str): - return self._parse_timecode_string(other) + return self._seconds_to_frames(self._timecode_to_seconds(other)) if isinstance(other, FrameTimecode): # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. if self._framerate and other._framerate and not self.equal_framerate(other._framerate): @@ -421,53 +433,73 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] return other._frame_num # If other has no frame_num, it must have a timecode. Convert to frames. return self._seconds_to_frames(other.seconds) - raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") + raise TypeError("Cannot obtain frame number for this timecode.") def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if self._timecode: - new_seconds = self.seconds + self._get_other_as_seconds(other) - # TODO: This is incorrect for VFR, need a better way to handle this. - # For now, we convert back to a frame number. - self._frame_num = self._seconds_to_frames(new_seconds) - self._timecode = None - else: - self._frame_num += self._get_other_as_frames(other) - if self._frame_num < 0: # Required to allow adding negative seconds/frames. - self._frame_num = 0 + other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + + if self._timecode and other_has_timecode: + if self._timecode.time_base != other._timecode.time_base: + raise ValueError("timecodes have different time bases") + self._timecode = Timecode( + pts=max(0, self._timecode.pts + other._timecode.pts), + time_base=self._timecode.time_base, + ) + return self + + # If either input is a timecode, the output shall also be one. The input which isn't a + # timecode is converted into seconds, after which the equivalent timecode is computed. + if self._timecode or other_has_timecode: + timecode: Timecode = self._timecode if self._timecode else other._timecode + seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds + self._timecode = Timecode( + pts=max(0, timecode.pts + round(seconds / timecode.time_base)), + time_base=timecode.time_base, + ) + self._seconds = None + self._framerate = None + self._frame_num = None + return self + + if self._seconds and other._seconds: + self._seconds = max(0, self._seconds + other._seconds) + return self + + self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -476,16 +508,36 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if self._timecode: - new_seconds = self.seconds - self._get_other_as_seconds(other) - # TODO: This is incorrect for VFR, need a better way to handle this. - # For now, we convert back to a frame number. - self._frame_num = self._seconds_to_frames(new_seconds) - self._timecode = None - else: - self._frame_num -= self._get_other_as_frames(other) - if self._frame_num < 0: - self._frame_num = 0 + other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + + if self._timecode and other_has_timecode: + if self._timecode.time_base != other._timecode.time_base: + raise ValueError("timecodes have different time bases") + self._timecode = Timecode( + pts=max(0, self._timecode.pts - other._timecode.pts), + time_base=self._timecode.time_base, + ) + return self + + # If either input is a timecode, the output shall also be one. The input which isn't a + # timecode is converted into seconds, after which the equivalent timecode is computed. + if self._timecode or other_has_timecode: + timecode: Timecode = self._timecode if self._timecode else other._timecode + seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds + self._timecode = Timecode( + pts=max(0, timecode.pts - round(seconds / timecode.time_base)), + time_base=timecode.time_base, + ) + self._seconds = None + self._framerate = None + self._frame_num = None + return self + + if self._seconds and other._seconds: + self._seconds = max(0, self._seconds - other._seconds) + return self + + self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -518,13 +570,18 @@ def __hash__(self) -> int: def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): + if self._timecode: + # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses + # frame numbers with timestamps to convert to a non-frame based way of temporal + # logic and instead use seconds-based. + if _USE_PTS_IN_DEVELOPMENT and other == 1: + return self.seconds + raise NotImplementedError() return float(other) / self._framerate if isinstance(other, float): return other if isinstance(other, str): - # This is not ideal, but we need a framerate to parse strings. - # We create a temporary FrameTimecode to do this. - return FrameTimecode(timecode=other, fps=self._framerate).seconds + return self._timecode_to_seconds(other) if isinstance(other, FrameTimecode): return other.seconds raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 1b66ff7b..05edeb61 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -137,7 +137,7 @@ def __init__( raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: ty.Optional[float] = None - # TODO(v0.7): Handle timecodes in filter. + # TODO(https://scenedetect.com/issue/168): Handle timecodes in filter. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index c948f9d1..2cfe05b8 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -111,7 +111,7 @@ def process_frame( ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - # TODO(v0.7): We need to consider PTS here instead. The methods below using frame numbers + # TODO(https://scenedetect.com/issue/168): We need to consider PTS here instead. The methods below using frame numbers # won't work for variable framerates. frame_num = timecode.frame_num From 0464ccf7063f1fcba131c6e557b3352293e72c32 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 30 Sep 2025 20:47:01 -0400 Subject: [PATCH 020/163] [timecode] Fix some failing timecode tests after updated arithmetic --- scenedetect/common.py | 33 ++++++++++++++++++++++++++++----- tests/test_cli.py | 2 +- tests/test_timecode.py | 27 +++++---------------------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index e3b8cb1d..40980373 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -210,9 +210,10 @@ def __init__( # Process the timecode value, storing it as an exact number of frames. if isinstance(timecode, str): self._seconds = self._timecode_to_seconds(timecode) - self._frame_num = self._seconds_to_frames(self._timecode_to_seconds(timecode)) + self._frame_num = self._seconds_to_frames(self._seconds) else: self._frame_num = self._parse_timecode_number(timecode) + self._seconds = timecode if isinstance(timecode, float) else None @property def frame_num(self) -> ty.Optional[int]: @@ -379,14 +380,14 @@ def _timecode_to_seconds(self, input: str) -> float: Raises: ValueError: Value could not be parsed correctly. """ - assert self._framerate is not None + assert self._framerate is not None and self._framerate > MAX_FPS_DELTA input = input.strip() # Exact number of frames N if input.isdigit(): timecode = int(input) if timecode < 0: raise ValueError("Timecode frame number must be positive.") - return timecode * self.framerate + return timecode / self.framerate # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") @@ -438,6 +439,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False + if _compare_as_fixed(self, other): + return self._frame_num == other._frame_num if self._timecode or self._seconds is not None: return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -445,26 +448,36 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True + if _compare_as_fixed(self, other): + return self._frame_num != other._frame_num if self._timecode or self._seconds is not None: return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num < other._frame_num if self._timecode or self._seconds is not None: return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num <= other._frame_num if self._timecode or self._seconds is not None: return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num > other._frame_num if self._timecode or self._seconds is not None: return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num >= other._frame_num if self._timecode or self._seconds is not None: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) @@ -495,11 +508,14 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._frame_num = None return self - if self._seconds and other._seconds: + other_has_seconds = isinstance(other, FrameTimecode) and other._seconds + if self._seconds is not None and other_has_seconds: self._seconds = max(0, self._seconds + other._seconds) return self self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) + if self._seconds is not None: + self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other)) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -533,11 +549,14 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._frame_num = None return self - if self._seconds and other._seconds: + other_has_seconds = isinstance(other, FrameTimecode) and other._seconds + if self._seconds is not None and other_has_seconds: self._seconds = max(0, self._seconds - other._seconds) return self self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) + if self._seconds is not None: + self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other)) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -585,3 +604,7 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" if isinstance(other, FrameTimecode): return other.seconds raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") + + +def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: + return a._framerate is not None and isinstance(b, FrameTimecode) and b._framerate is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index 61022bf8..7ba8da2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -691,7 +691,7 @@ def test_cli_load_scenes_with_time_frames(): def test_cli_load_scenes_round_trip(): - """Verify we can use `load-scenes` with the `time` command and get the desired output.""" + """Verify we can use `load-scenes` and get the same scenes as output with `list-scenes`.""" scenes_csv = """ Scene Number,Start Frame 1,49 diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 20fba42b..2f326a24 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -187,7 +187,8 @@ def test_get_timecode(): assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.500" + # If a value is provided in seconds, we store that value internally now. + assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.501" assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" @@ -199,12 +200,6 @@ def test_equality(): assert x == FrameTimecode(timecode=1.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) - # Comparing FrameTimecodes with different framerates raises a TypeError. - with pytest.raises(ValueError): - assert x == FrameTimecode(timecode=1.0, fps=100.0) - with pytest.raises(ValueError): - assert x == FrameTimecode(timecode=1.0, fps=10.1) - assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) @@ -232,11 +227,6 @@ def test_equality(): assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.501" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.502" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.508" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.509" - assert FrameTimecode(timecode="00:00:01.519", fps=10) == "00:00:01.510" def test_addition(): @@ -244,14 +234,11 @@ def test_addition(): x = FrameTimecode(timecode=1.0, fps=10.0) assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) assert x + 1 == FrameTimecode(1.1, x) + assert x + 10 == "00:00:02.000", str(x + 10) assert x + 10 == 20 assert x + 10 == 2.0 - assert x + 10 == "00:00:02.000" - with pytest.raises(ValueError): - assert FrameTimecode("00:00:02.000", fps=20.0) == x + 10 - def test_subtraction(): """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" @@ -259,17 +246,13 @@ def test_subtraction(): assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) assert x - 2 == FrameTimecode(0.8, x) assert x - 10 == FrameTimecode(0.0, x) - # TODO(v1.0): Allow negative values + # TODO(v1.0): Allow negative values. For now we clamp. assert x - 11 == FrameTimecode(0.0, x) assert x - 100 == FrameTimecode(0.0, x) - assert x - 1.0 == FrameTimecode(0.0, x) assert x - 100.0 == FrameTimecode(0.0, x) - assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) - - with pytest.raises(ValueError): - assert FrameTimecode("00:00:02.000", fps=20.0) == x - 10 + assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10 @pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) From 0a8b68dddaaf160d5dc298c5e3430460211e656e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 30 Sep 2025 22:26:43 -0400 Subject: [PATCH 021/163] [timecode] Fix remaining timecode issues --- scenedetect/_cli/controller.py | 13 +++++--- scenedetect/common.py | 55 ++++++++++++++++++++++++---------- tests/test_timecode.py | 10 +++++-- website/pages/changelog.md | 1 + 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index eee05267..be7ac8d2 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -175,10 +175,15 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: if context.load_scenes_column_name not in csv_headers: raise ValueError("specified column header for scene start is not present") col_idx = csv_headers.index(context.load_scenes_column_name) - cut_list = sorted( - FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 - for row in file_reader - ) + + def calculate_timecode(value: str) -> FrameTimecode: + # Assume other columns are in seconds except frame numbers. + if value.isdigit(): + # Frame numbers start from index 1 in the CLI output so we correct for that. + return FrameTimecode(int(value) - 1, fps=context.video_stream.frame_rate) + return FrameTimecode(value, fps=context.video_stream.frame_rate) + + cut_list = sorted(calculate_timecode(row[col_idx]) for row in file_reader) # `SceneDetector` works on cuts, so we have to skip the first scene and place the first # cut point where the next scenes starts. if cut_list: diff --git a/scenedetect/common.py b/scenedetect/common.py index 40980373..342d6cd4 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -207,17 +207,27 @@ def __init__( ): raise ValueError("Framerate must be positive and greater than zero.") self._framerate = float(fps) - # Process the timecode value, storing it as an exact number of frames. + # Process the timecode value, storing it as an exact number of frames only if required. + if isinstance(timecode, str) and timecode.isdigit(): + timecode = int(timecode) if isinstance(timecode, str): self._seconds = self._timecode_to_seconds(timecode) - self._frame_num = self._seconds_to_frames(self._seconds) + elif isinstance(timecode, float): + if timecode < 0.0: + raise ValueError("Timecode frame number must be positive and greater than zero.") + self._seconds = timecode + elif isinstance(timecode, int): + if timecode < 0: + raise ValueError("Timecode frame number must be positive and greater than zero.") + self._frame_num = timecode else: - self._frame_num = self._parse_timecode_number(timecode) - self._seconds = timecode if isinstance(timecode, float) else None + raise TypeError("Timecode format/type unrecognized.") @property def frame_num(self) -> ty.Optional[int]: if self._timecode: + # We need to audit anything currently using this property to guarantee temporal + # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). warnings.warn( message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", stacklevel=2, @@ -227,6 +237,8 @@ def frame_num(self) -> ty.Optional[int]: # time base itself. (num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio() return num / den + if self._seconds is not None: + return self._seconds_to_frames(self._seconds) return self._frame_num @property @@ -306,19 +318,26 @@ def get_seconds(self) -> float: ) return self.seconds - def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: + def get_timecode( + self, precision: int = 3, use_rounding: bool = True, nearest_frame: bool = True + ) -> str: """Get a formatted timecode string of the form HH:MM:SS[.nnn]. Args: precision: The number of decimal places to include in the output ``[.nnn]``. use_rounding: Rounds the output to the desired precision. If False, the value will be truncated to the specified precision. + nearest_frame: Ensures that the timecode is moved to the nearest frame boundary if this + object has a defined framerate, otherwise has no effect. Returns: str: The current time in the form ``"HH:MM:SS[.nnn]"``. """ # Compute hours and minutes based off of seconds, and update seconds. - secs = self.seconds + if nearest_frame and self.framerate: + secs = self.frame_num / self.framerate + else: + secs = self.seconds hrs = int(secs / _SECONDS_PER_HOUR) secs -= hrs * _SECONDS_PER_HOUR mins = int(secs / _SECONDS_PER_MINUTE) @@ -440,7 +459,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False if _compare_as_fixed(self, other): - return self._frame_num == other._frame_num + return self.frame_num == other.frame_num if self._timecode or self._seconds is not None: return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -449,35 +468,35 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True if _compare_as_fixed(self, other): - return self._frame_num != other._frame_num + return self.frame_num != other.frame_num if self._timecode or self._seconds is not None: return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num < other._frame_num + return self.frame_num < other.frame_num if self._timecode or self._seconds is not None: return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num <= other._frame_num + return self.frame_num <= other.frame_num if self._timecode or self._seconds is not None: return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num > other._frame_num + return self.frame_num > other.frame_num if self._timecode or self._seconds is not None: return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num >= other._frame_num + return self.frame_num >= other.frame_num if self._timecode or self._seconds is not None: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) @@ -513,9 +532,11 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._seconds = max(0, self._seconds + other._seconds) return self - self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) if self._seconds is not None: self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other)) + return self + + self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -554,9 +575,11 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._seconds = max(0, self._seconds - other._seconds) return self - self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) if self._seconds is not None: self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other)) + return self + + self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -579,7 +602,9 @@ def __str__(self) -> str: def __repr__(self) -> str: if self._timecode: return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" - return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self._frame_num, self._framerate) + if self._seconds is not None: + return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._framerate}]" + return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._framerate}]" def __hash__(self) -> int: if self._timecode: diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 2f326a24..f4ea74c5 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -188,8 +188,14 @@ def test_get_timecode(): assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" # If a value is provided in seconds, we store that value internally now. - assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.501" - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=False) + == "00:00:01.501" + ) + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=True) + == "00:00:01.500" + ) def test_equality(): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6f5f96d3..5ad1a3d3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -713,3 +713,4 @@ Although there have been minimal changes to most API examples, there are several * Deprecated functionality preserved from v0.6 now uses the `warnings` module * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods * Add new `Timecode` type to represent frame timings in terms of the video's source timebase + * Expand `FrameTimecode` representations to preserve accuracy (previously all timecodes were rounded to frame boundaries) \ No newline at end of file From e86147f4e8dadc38709609bc0edc57d626bbcb39 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 1 Oct 2025 21:35:22 -0400 Subject: [PATCH 022/163] [timecode] Use fractions for internal framerate representation --- scenedetect/common.py | 49 ++++++++++-------- tests/test_timecode.py | 111 ++++++++++++++++++++++------------------- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 342d6cd4..c09b12c4 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -92,8 +92,8 @@ TimecodePair = ty.Tuple["FrameTimecode", "FrameTimecode"] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" -MAX_FPS_DELTA: float = 1.0 / 100000 -"""Maximum amount two framerates can differ by for equality testing.""" +MAX_FPS_DELTA: float = 1.0 / 1000000000.0 +"""Maximum amount two framerates can differ by for equality testing. Currently 1 frame/nanosec.""" _SECONDS_PER_MINUTE = 60.0 _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE @@ -160,13 +160,14 @@ class FrameTimecode: def __init__( self, timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None, - fps: ty.Union[int, float, str, "FrameTimecode"] = None, + fps: ty.Union[float, "FrameTimecode", Fraction] = None, ): """ Arguments: timecode: A frame number (`int`), number of seconds (`float`), timecode string in the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`, or a `Timecode`. - fps: The framerate or FrameTimecode to use as a time base for all arithmetic. + fps: The framerate to use for distance between frames and to calculate frame numbers. + For a VFR video, this may just be the average framerate. Raises: TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. @@ -175,7 +176,7 @@ def __init__( # in a frame-specific manner. Note that once the framerate is set, # the value should never be modified (only read if required). # TODO(v1.0): Make these actual @properties. - self._framerate = fps + self._framerate: Fraction = None self._frame_num = None self._timecode: ty.Optional[Timecode] = None self._seconds: ty.Optional[float] = None @@ -188,25 +189,31 @@ def __init__( self._seconds = timecode._seconds return - # Timecode. - if isinstance(timecode, Timecode): - self._timecode = timecode - return + if not isinstance(fps, (float, Fraction, FrameTimecode)): + raise TypeError("fps must be of type float, Fraction, or FrameTimecode.") # Ensure args are consistent with API. if fps is None: - raise TypeError("Framerate (fps) is a required argument.") + raise TypeError("fps is a required argument.") if isinstance(fps, FrameTimecode): - fps = fps._framerate - - # Process the given framerate, if it was not already set. - if not isinstance(fps, (int, float)): - raise TypeError("Framerate must be of type int/float.") - if (isinstance(fps, int) and not fps > 0) or ( - isinstance(fps, float) and not fps >= MAX_FPS_DELTA - ): - raise ValueError("Framerate must be positive and greater than zero.") - self._framerate = float(fps) + self._framerate = fps._framerate + elif isinstance(fps, float): + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + self._framerate = Fraction.from_float(fps) + elif isinstance(fps, Fraction): + if float(fps) <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + self._framerate = fps + else: + raise TypeError( + f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" + ) + + # Timecode with a time base. + if isinstance(timecode, Timecode): + self._timecode = timecode + return # Process the timecode value, storing it as an exact number of frames only if required. if isinstance(timecode, str) and timecode.isdigit(): timecode = int(timecode) @@ -243,7 +250,7 @@ def frame_num(self) -> ty.Optional[int]: @property def framerate(self) -> ty.Optional[float]: - return self._framerate + return float(self._framerate) def get_frames(self) -> int: """[DEPRECATED] Get the current time/position in number of frames. diff --git a/tests/test_timecode.py b/tests/test_timecode.py index f4ea74c5..cbddeb79 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -25,6 +25,7 @@ # Standard Library Imports from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from fractions import Fraction def test_framerate(): @@ -38,11 +39,11 @@ def test_framerate(): FrameTimecode(timecode=None, fps=FrameTimecode(timecode=0, fps=None)) # Test zero FPS/negative. with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=0) + FrameTimecode(timecode=0, fps=0.0) with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-1) + FrameTimecode(timecode=0, fps=-1.0) with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-100) + FrameTimecode(timecode=0, fps=-100.0) with pytest.raises(ValueError): FrameTimecode(timecode=0, fps=0.0) with pytest.raises(ValueError): @@ -52,26 +53,28 @@ def test_framerate(): with pytest.raises(ValueError): FrameTimecode(timecode=0, fps=MAX_FPS_DELTA / 2) # Test positive framerates. - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 - assert FrameTimecode(timecode=0, fps=10).frame_num == 0 + assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=10.0).frame_num == 0 assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA * 2).frame_num == 0 - assert FrameTimecode(timecode=0, fps=1000).frame_num == 0 assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 + # Reject framerates too small for equality testing or potential divide by zero situations. + with pytest.raises(ValueError): + assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 def test_timecode_numeric(): """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" with pytest.raises(ValueError): - FrameTimecode(timecode=-1, fps=1) + FrameTimecode(timecode=-1, fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode=-1.0, fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode=-0.1, fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode=-1.0 / 1000, fps=1.0) - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=1, fps=1).frame_num == 1 + assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1 assert FrameTimecode(timecode=0.0, fps=1.0).frame_num == 0 assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == 1 @@ -80,13 +83,13 @@ def test_timecode_string(): """Test FrameTimecode constructor argument "timecode" with string arguments.""" # Invalid strings: with pytest.raises(ValueError): - FrameTimecode(timecode="-1", fps=1) + FrameTimecode(timecode="-1", fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode="-1.0", fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode="-0.1", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode="1.9x", fps=1) + FrameTimecode(timecode="1.9x", fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode="1x", fps=1.0) with pytest.raises(ValueError): @@ -95,21 +98,21 @@ def test_timecode_string(): FrameTimecode(timecode="1.0-", fps=1.0) # Frame number integer [int->str] ('%d', integer number as string) - assert FrameTimecode(timecode="0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1", fps=1).frame_num == 1 + assert FrameTimecode(timecode="0", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1", fps=1.0).frame_num == 1 assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 # Seconds format [float->str] ('%f', number as string) - assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1 + assert FrameTimecode(timecode="0.0", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1.0", fps=1.0).frame_num == 1 assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) - assert FrameTimecode(timecode="0s", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1s", fps=1).frame_num == 1 + assert FrameTimecode(timecode="0s", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1s", fps=1.0).frame_num == 1 assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 @@ -117,34 +120,34 @@ def test_timecode_string(): assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) - assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1 - assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:01", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="00:00:01.9999", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0001", fps=1.0).frame_num == 2 # MM:SS[.nnn] is also allowed - assert FrameTimecode(timecode="00:01", fps=1).frame_num == 1 - assert FrameTimecode(timecode="00:01.9999", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:02.0001", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:01", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="00:01.9999", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:02.0001", fps=1.0).frame_num == 2 # Conversion edge cases - assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 - assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 - assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 - assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1 + assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10 + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5 + assert FrameTimecode(timecode="00:00:00.100", fps=10.0).frame_num == 1 + assert FrameTimecode(timecode="00:00:00.001", fps=1000.0).frame_num == 1 - assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:00:59.999", fps=1.0).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.001", fps=1.0).frame_num == 60 - assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 + assert FrameTimecode(timecode="00:59:59.999", fps=1.0).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.000", fps=1.0).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.001", fps=1.0).frame_num == 3600 # Check too many ":" characters (https://github.com/Breakthrough/PySceneDetect/issues/476) with pytest.raises(ValueError): - FrameTimecode(timecode="01:01:00:00.001", fps=1) + FrameTimecode(timecode="01:01:00:00.001", fps=1.0) def test_get_frames(): @@ -157,10 +160,10 @@ def test_get_frames(): assert FrameTimecode(timecode=1000.0, fps=60.0).frame_num == int(1000.0 * 60.0) assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == int(1000000000.0 * 29.97) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 - assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 - assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5 + assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10 + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60 def test_get_seconds(): @@ -173,10 +176,10 @@ def test_get_seconds(): assert FrameTimecode(timecode=1000.0, fps=60.0).seconds, pytest.approx(1000.0) assert FrameTimecode(timecode=1000000000.0, fps=29.97).seconds, pytest.approx(1000000000.0) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).seconds, pytest.approx(2.0) - assert FrameTimecode(timecode="00:00:00.5", fps=10).seconds, pytest.approx(0.5) - assert FrameTimecode(timecode="00:00:01", fps=10).seconds, pytest.approx(1.0) - assert FrameTimecode(timecode="00:01:00.000", fps=1).seconds, pytest.approx(60.0) + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).seconds, pytest.approx(2.0) + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).seconds, pytest.approx(0.5) + assert FrameTimecode(timecode="00:00:01", fps=10.0).seconds, pytest.approx(1.0) + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).seconds, pytest.approx(60.0) def test_get_timecode(): @@ -185,15 +188,15 @@ def test_get_timecode(): assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117" assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234" - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).get_timecode() == "00:00:02.000" + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).get_timecode() == "00:00:00.500" # If a value is provided in seconds, we store that value internally now. assert ( - FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=False) + FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=False) == "00:00:01.501" ) assert ( - FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=True) + FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=True) == "00:00:01.500" ) @@ -204,8 +207,10 @@ def test_equality(): assert x == x assert x == FrameTimecode(timecode=1.0, fps=10.0) assert x == FrameTimecode(timecode=1.0, fps=10.0) + assert x == FrameTimecode(timecode=1.0, fps=Fraction(10, 1)) assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) + assert x != FrameTimecode(timecode=10.0, fps=Fraction(100, 10)) assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) @@ -231,8 +236,8 @@ def test_equality(): with pytest.raises(TypeError): assert x == {0: 0} - assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" + assert FrameTimecode(timecode="00:00:00.5", fps=10.0) == "00:00:00.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10.0) == "00:00:01.500" def test_addition(): @@ -261,7 +266,9 @@ def test_subtraction(): assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10 -@pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) +@pytest.mark.parametrize( + "frame_num,fps", [(1, 1.0), (61, 14.0), (29, 25.0), (126, Fraction(24000, 1001))] +) def test_identity(frame_num, fps): """Test FrameTimecode values, when used in init return the same values""" frame_time_code = FrameTimecode(frame_num, fps=fps) From a501c3b0f3e810b119717ab1c70a1d907b1b5d8b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 1 Oct 2025 21:49:01 -0400 Subject: [PATCH 023/163] [timecode] Add pts and time base properties --- scenedetect/backends/opencv.py | 7 ++- scenedetect/backends/pyav.py | 28 ++++++---- scenedetect/common.py | 66 +++++++++++++++-------- scenedetect/detector.py | 37 ++++++------- scenedetect/detectors/content_detector.py | 3 +- scenedetect/output/image.py | 5 +- scenedetect/scene_manager.py | 15 +++--- scenedetect/video_stream.py | 1 + tests/test_timecode.py | 3 +- website/pages/changelog.md | 3 +- 10 files changed, 99 insertions(+), 69 deletions(-) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 44696cef..24d7d3c5 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -206,6 +206,8 @@ def timecode(self) -> Timecode: @property def position(self) -> FrameTimecode: + # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or + # add a config option before landing this. if _USE_PTS_IN_DEVELOPMENT: return FrameTimecode(timecode=self.timecode, fps=self.frame_rate) if self.frame_number < 1: @@ -226,10 +228,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if target < 0: raise ValueError("Target seek position cannot be negative!") - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. - raise NotImplementedError() - + # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. # Have to seek one behind and call grab() after to that the VideoCapture # returns a valid timestamp when using CAP_PROP_POS_MSEC. target_frame_cv2 = (self.base_timecode + target).frame_num diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 35e7e90f..b6537774 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -12,6 +12,7 @@ """:class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" import typing as ty +from fractions import Fraction from logging import getLogger import av @@ -22,7 +23,6 @@ from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") - VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] @@ -36,7 +36,7 @@ class VideoStreamAv(VideoStream): def __init__( self, path_or_io: ty.Union[ty.AnyStr, ty.BinaryIO], - framerate: ty.Optional[float] = None, + framerate: ty.Optional[ty.Union[float, Fraction]] = None, name: ty.Optional[str] = None, threading_mode: ty.Optional[str] = None, suppress_output: bool = False, @@ -123,15 +123,14 @@ def __init__( ) if frame_rate is None or frame_rate == 0: raise FrameRateUnavailable() - # TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations. - # See https://pyav.org/docs/develop/api/stream.html for details. - frame_rate = frame_rate.numerator / float(frame_rate.denominator) if frame_rate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._frame_rate: float = frame_rate + self._frame_rate: Fraction = frame_rate else: assert framerate >= MAX_FPS_DELTA - self._frame_rate: float = framerate + self._frame_rate: Fraction = ( + framerate if isinstance(framerate, Fraction) else Fraction.from_float(framerate) + ) # Calculate duration after we have set the framerate. self._duration_frames = self._get_duration() @@ -212,6 +211,16 @@ def frame_number(self) -> int: return self.position.frame_num + 1 return 0 + @property + def rate(self) -> Fraction: + return self._video_stream.guessed_rate + + @property + def time_base(self) -> Fraction: + if self._frame: + return self._frame.time_base + return None + @property def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" @@ -250,10 +259,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: raise ValueError("Target cannot be negative!") beginning = target == 0 - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. - raise NotImplementedError() - + # TODO(https://scenedetect.com/issues/168): This breaks with PTS mode enabled. target = self.base_timecode + target if target >= 1: target = target - 1 diff --git a/scenedetect/common.py b/scenedetect/common.py index c09b12c4..4db67d5e 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -172,18 +172,21 @@ def __init__( TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. """ - # The following two properties are what is used to keep track of time - # in a frame-specific manner. Note that once the framerate is set, - # the value should never be modified (only read if required). - # TODO(v1.0): Make these actual @properties. - self._framerate: Fraction = None + # NOTE: FrameTimecode will have either a `Timecode` representation, a `seconds` + # representation, or only a frame number. We cache the calculated values for later use + # for the parameters that are missing. + self._rate: Fraction = None + """Rate at which time passes between frames, measured in frames/sec.""" self._frame_num = None + """Frame number which may be estimated.""" self._timecode: ty.Optional[Timecode] = None + """Presentation timestamp from the backend.""" self._seconds: ty.Optional[float] = None + """An explicit point in time.""" # Copy constructor. if isinstance(timecode, FrameTimecode): - self._framerate = timecode._framerate if fps is None else fps + self._rate = timecode._rate if fps is None else fps self._frame_num = timecode._frame_num self._timecode = timecode._timecode self._seconds = timecode._seconds @@ -196,15 +199,15 @@ def __init__( if fps is None: raise TypeError("fps is a required argument.") if isinstance(fps, FrameTimecode): - self._framerate = fps._framerate + self._rate = fps._rate elif isinstance(fps, float): if fps <= MAX_FPS_DELTA: raise ValueError("Framerate must be positive and greater than zero.") - self._framerate = Fraction.from_float(fps) + self._rate = Fraction.from_float(fps) elif isinstance(fps, Fraction): if float(fps) <= MAX_FPS_DELTA: raise ValueError("Framerate must be positive and greater than zero.") - self._framerate = fps + self._rate = fps else: raise TypeError( f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" @@ -214,9 +217,11 @@ def __init__( if isinstance(timecode, Timecode): self._timecode = timecode return + # Process the timecode value, storing it as an exact number of frames only if required. if isinstance(timecode, str) and timecode.isdigit(): timecode = int(timecode) + if isinstance(timecode, str): self._seconds = self._timecode_to_seconds(timecode) elif isinstance(timecode, float): @@ -232,6 +237,8 @@ def __init__( @property def frame_num(self) -> ty.Optional[int]: + """The frame number. This value will be an estimate if the video is VFR. Prefer using the + `pts` property.""" if self._timecode: # We need to audit anything currently using this property to guarantee temporal # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). @@ -249,8 +256,24 @@ def frame_num(self) -> ty.Optional[int]: return self._frame_num @property - def framerate(self) -> ty.Optional[float]: - return float(self._framerate) + def framerate(self) -> float: + """The framerate to use for distance between frames and to calculate frame numbers. + For a VFR video, this may just be the average framerate.""" + return float(self._rate) + + @property + def time_base(self) -> Fraction: + """The time base in which presentation time is calculated.""" + if self._timecode: + return self._timecode.time_base + return 1 / self._rate + + @property + def pts(self) -> int: + """The presentation timestamp of the frame in units of `time_base`.""" + if self._timecode: + return self._timecode.pts + return self.frame_num def get_frames(self) -> int: """[DEPRECATED] Get the current time/position in number of frames. @@ -302,8 +325,7 @@ def seconds(self) -> float: return self._timecode.seconds if self._seconds: return self._seconds - # Assume constant framerate if we don't have timing information. - return float(self._frame_num) / self._framerate + return float(self._frame_num / self._rate) def get_seconds(self) -> float: """[DEPRECATED] Get the frame's position in number of seconds. @@ -372,7 +394,7 @@ def _seconds_to_frames(self, seconds: float) -> int: *NOTE*: This will not be correct for variable framerate videos. """ - return round(seconds * self._framerate) + return round(seconds * self._rate) def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: """Parse a timecode number, storing it as the exact number of frames. @@ -406,7 +428,7 @@ def _timecode_to_seconds(self, input: str) -> float: Raises: ValueError: Value could not be parsed correctly. """ - assert self._framerate is not None and self._framerate > MAX_FPS_DELTA + assert self._rate is not None and self._rate > MAX_FPS_DELTA input = input.strip() # Exact number of frames N if input.isdigit(): @@ -452,7 +474,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] return self._seconds_to_frames(self._timecode_to_seconds(other)) if isinstance(other, FrameTimecode): # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. - if self._framerate and other._framerate and not self.equal_framerate(other._framerate): + if self._rate and other._rate and not self.equal_framerate(other._rate): raise ValueError( "FrameTimecode instances require equal framerate for frame-based arithmetic." ) @@ -530,7 +552,7 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT time_base=timecode.time_base, ) self._seconds = None - self._framerate = None + self._rate = None self._frame_num = None return self @@ -573,7 +595,7 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT time_base=timecode.time_base, ) self._seconds = None - self._framerate = None + self._rate = None self._frame_num = None return self @@ -610,8 +632,8 @@ def __repr__(self) -> str: if self._timecode: return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" if self._seconds is not None: - return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._framerate}]" - return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._framerate}]" + return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._rate}]" + return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._rate}]" def __hash__(self) -> int: if self._timecode: @@ -628,7 +650,7 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" if _USE_PTS_IN_DEVELOPMENT and other == 1: return self.seconds raise NotImplementedError() - return float(other) / self._framerate + return float(other) / self._rate if isinstance(other, float): return other if isinstance(other, str): @@ -639,4 +661,4 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: - return a._framerate is not None and isinstance(b, FrameTimecode) and b._framerate is not None + return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 5f903601..e7d9e731 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -30,7 +30,7 @@ import numpy -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode +from scenedetect.common import FrameTimecode from scenedetect.stats_manager import StatsManager @@ -49,7 +49,7 @@ def __init__(self): def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray ) -> ty.List[FrameTimecode]: - """Process the next frame. `frame_num` is assumed to be sequential. + """Process the next frame. `timecode` is assumed to be sequential. Args: timecode: Timecode corresponding to the frame being processed. @@ -74,9 +74,8 @@ def post_process(self, timecode: int) -> ty.List[FrameTimecode]: @property def event_buffer_length(self) -> int: - """The amount of frames a given event can be buffered for, in time. Represents maximum - amount any event can be behind `frame_number` in the result of :meth:`process_frame`. - """ + """The amount of frames a given event can be buffered for, in time. This must be set to the + amount of frames a detector might emit an event in the past.""" return 0 # Frame Stats/Metrics @@ -135,30 +134,32 @@ def max_behind(self) -> int: def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: if not self._filter_length > 0: return [timecode] if above_threshold else [] - if _USE_PTS_IN_DEVELOPMENT: - raise NotImplementedError("TODO: Change filter to use units of time instead of frames.") if self._last_above is None: self._last_above = timecode if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=timecode, above_threshold=above_threshold) + return self._filter_merge(timecode=timecode, above_threshold=above_threshold) elif self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=timecode, above_threshold=above_threshold) + return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") - def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + framerate = timecode.framerate + assert framerate >= 0 + min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) if not (above_threshold and min_length_met): return [] # Both length and threshold requirements were satisfied. Emit the cut, and wait until both # requirements are met again. - self._last_above = frame_num - return [frame_num] + self._last_above = timecode + return [timecode] - def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + framerate = timecode.framerate + assert framerate >= 0 + min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: - self._last_above = frame_num + self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. num_merged_frames = self._last_above - self._merge_start @@ -174,9 +175,9 @@ def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if min_length_met: # Only allow the merge filter once the first cut is emitted. self._merge_enabled = True - return [frame_num] + return [timecode] # Start merging cuts until the length requirement is met. if self._merge_enabled: self._merge_triggered = True - self._merge_start = frame_num + self._merge_start = timecode return [] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 05edeb61..6dd2355e 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -137,7 +137,8 @@ def __init__( raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: ty.Optional[float] = None - # TODO(https://scenedetect.com/issue/168): Handle timecodes in filter. + # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling + # `min_scene_len` which should be specified in seconds, not frames. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 3df176c9..3fa54b04 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -293,7 +293,8 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config parameters.""" - framerate = scene_list[0][0]._framerate + # TODO(v0.7): This needs to be fixed as part of PTS overhaul. + framerate = scene_list[0][0].framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. return [ ( @@ -450,7 +451,7 @@ def save_images( image_num_format = "%0" image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - framerate = scene_list[0][0]._framerate + framerate = scene_list[0][0]._rate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. timecode_list = [ diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 890d9fb1..f4982ef8 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -87,7 +87,6 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import numpy as np from scenedetect.common import ( - _USE_PTS_IN_DEVELOPMENT, CropRegion, CutList, FrameTimecode, @@ -232,7 +231,7 @@ def __init__( self._exception_info = None self._stop = threading.Event() - self._frame_buffer = [] + self._frame_buffer: ty.List[ty.Tuple[FrameTimecode, np.ndarray]] = [] self._frame_buffer_size = 0 self._crop = None @@ -385,7 +384,7 @@ def _process_frame( self, position: FrameTimecode, frame_im: np.ndarray, - callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, + callback: ty.Optional[ty.Callable[[np.ndarray, FrameTimecode], None]] = None, ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" @@ -393,7 +392,7 @@ def _process_frame( # TODO(https://scenedetect.com/issues/283): This breaks with AdaptiveDetector as cuts differ # from the frame number being processed. Allow detectors to specify the max frame lookahead # they require (i.e. any event will never be more than N frames behind the current one). - self._frame_buffer.append(frame_im) + self._frame_buffer.append((position, frame_im)) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] @@ -401,13 +400,11 @@ def _process_frame( cuts = detector.process_frame(position, frame_im) self._cutting_list += cuts new_cuts = True if cuts else False - # TODO: Support callbacks with PTS. if callback: - if _USE_PTS_IN_DEVELOPMENT: - raise NotImplementedError() for cut in cuts: - buffer_index = cut.frame_num - (position.frame_num + 1) - callback(self._frame_buffer[buffer_index], cut.frame_num) + for position, frame in self._frame_buffer: + if cut == position: + callback(frame, position) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 772a922a..26c2cefe 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -34,6 +34,7 @@ import typing as ty from abc import ABC, abstractmethod from dataclasses import dataclass +from fractions import Fraction import numpy as np diff --git a/tests/test_timecode.py b/tests/test_timecode.py index cbddeb79..4d6c0d21 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -21,11 +21,12 @@ """ # Third-Party Library Imports +from fractions import Fraction + import pytest # Standard Library Imports from scenedetect.common import MAX_FPS_DELTA, FrameTimecode -from fractions import Fraction def test_framerate(): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 5ad1a3d3..6bc47db4 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -713,4 +713,5 @@ Although there have been minimal changes to most API examples, there are several * Deprecated functionality preserved from v0.6 now uses the `warnings` module * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods * Add new `Timecode` type to represent frame timings in terms of the video's source timebase - * Expand `FrameTimecode` representations to preserve accuracy (previously all timecodes were rounded to frame boundaries) \ No newline at end of file + * Add new `time_base` and `pts` properties to `FrameTimecode` to provide more accurate timing information + From f915f996b973af2781841cd86fed1a46a6172eb1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 4 Oct 2025 21:40:17 -0400 Subject: [PATCH 024/163] [timecode] Combine all representations into a variant type --- scenedetect/common.py | 179 ++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 84 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 4db67d5e..66d60e2f 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -147,6 +147,20 @@ def seconds(self) -> float: return float(self.time_base * self.pts) +@dataclass(frozen=True) +class _FrameNumber: + """Represents a time as a frame number.""" + + value: int + + +@dataclass(frozen=True) +class _Seconds: + """Represents a time in seconds.""" + + value: float + + class FrameTimecode: """Object for frame-based timecodes, using the video framerate to compute back and forth between frame number and seconds/timecode. @@ -172,24 +186,15 @@ def __init__( TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. """ - # NOTE: FrameTimecode will have either a `Timecode` representation, a `seconds` - # representation, or only a frame number. We cache the calculated values for later use - # for the parameters that are missing. + self._time: ty.Union[_FrameNumber, _Seconds, Timecode] + """Internal time representation.""" self._rate: Fraction = None """Rate at which time passes between frames, measured in frames/sec.""" - self._frame_num = None - """Frame number which may be estimated.""" - self._timecode: ty.Optional[Timecode] = None - """Presentation timestamp from the backend.""" - self._seconds: ty.Optional[float] = None - """An explicit point in time.""" # Copy constructor. if isinstance(timecode, FrameTimecode): self._rate = timecode._rate if fps is None else fps - self._frame_num = timecode._frame_num - self._timecode = timecode._timecode - self._seconds = timecode._seconds + self._time = timecode._time return if not isinstance(fps, (float, Fraction, FrameTimecode)): @@ -215,7 +220,7 @@ def __init__( # Timecode with a time base. if isinstance(timecode, Timecode): - self._timecode = timecode + self._time = timecode return # Process the timecode value, storing it as an exact number of frames only if required. @@ -223,15 +228,15 @@ def __init__( timecode = int(timecode) if isinstance(timecode, str): - self._seconds = self._timecode_to_seconds(timecode) + self._time = _Seconds(self._timecode_to_seconds(timecode)) elif isinstance(timecode, float): if timecode < 0.0: raise ValueError("Timecode frame number must be positive and greater than zero.") - self._seconds = timecode + self._time = _Seconds(timecode) elif isinstance(timecode, int): if timecode < 0: raise ValueError("Timecode frame number must be positive and greater than zero.") - self._frame_num = timecode + self._time = _FrameNumber(timecode) else: raise TypeError("Timecode format/type unrecognized.") @@ -239,7 +244,7 @@ def __init__( def frame_num(self) -> ty.Optional[int]: """The frame number. This value will be an estimate if the video is VFR. Prefer using the `pts` property.""" - if self._timecode: + if isinstance(self._time, Timecode): # We need to audit anything currently using this property to guarantee temporal # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). warnings.warn( @@ -249,11 +254,11 @@ def frame_num(self) -> ty.Optional[int]: ) # We can calculate the approx. # of frames by taking the presentation time and the # time base itself. - (num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio() + (num, den) = (self._time.time_base * self._time.pts).as_integer_ratio() return num / den - if self._seconds is not None: - return self._seconds_to_frames(self._seconds) - return self._frame_num + if isinstance(self._time, _Seconds): + return self._seconds_to_frames(self._time.value) + return self._time.value @property def framerate(self) -> float: @@ -264,15 +269,15 @@ def framerate(self) -> float: @property def time_base(self) -> Fraction: """The time base in which presentation time is calculated.""" - if self._timecode: - return self._timecode.time_base + if isinstance(self._time, Timecode): + return self._time.time_base return 1 / self._rate @property def pts(self) -> int: """The presentation timestamp of the frame in units of `time_base`.""" - if self._timecode: - return self._timecode.pts + if isinstance(self._time, Timecode): + return self._time.pts return self.frame_num def get_frames(self) -> int: @@ -321,11 +326,11 @@ def equal_framerate(self, fps) -> bool: @property def seconds(self) -> float: """The frame's position in number of seconds.""" - if self._timecode: - return self._timecode.seconds - if self._seconds: - return self._seconds - return float(self._frame_num / self._rate) + if isinstance(self._time, Timecode): + return self._time.seconds + if isinstance(self._time, _Seconds): + return self._time.value + return float(self._time.value / self._rate) def get_seconds(self) -> float: """[DEPRECATED] Get the frame's position in number of seconds. @@ -478,8 +483,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] raise ValueError( "FrameTimecode instances require equal framerate for frame-based arithmetic." ) - if other._frame_num is not None: - return other._frame_num + if isinstance(other._time, _FrameNumber): + return other._time.value # If other has no frame_num, it must have a timecode. Convert to frames. return self._seconds_to_frames(other.seconds) raise TypeError("Cannot obtain frame number for this timecode.") @@ -489,7 +494,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return False if _compare_as_fixed(self, other): return self.frame_num == other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -498,74 +503,76 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return True if _compare_as_fixed(self, other): return self.frame_num != other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num < other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num <= other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num > other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num >= other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) - if self._timecode and other_has_timecode: - if self._timecode.time_base != other._timecode.time_base: + if isinstance(self._time, Timecode) and other_is_timecode: + if self._time.time_base != other._time.time_base: raise ValueError("timecodes have different time bases") - self._timecode = Timecode( - pts=max(0, self._timecode.pts + other._timecode.pts), - time_base=self._timecode.time_base, + self._time = Timecode( + pts=max(0, self._time.pts + other._time.pts), + time_base=self._time.time_base, ) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if self._timecode or other_has_timecode: - timecode: Timecode = self._timecode if self._timecode else other._timecode - seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds - self._timecode = Timecode( + if isinstance(self._time, Timecode) or other_is_timecode: + timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time + seconds: float = ( + self._get_other_as_seconds(other) + if isinstance(self._time, Timecode) + else self.seconds + ) + self._time = Timecode( pts=max(0, timecode.pts + round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._seconds = None self._rate = None - self._frame_num = None return self - other_has_seconds = isinstance(other, FrameTimecode) and other._seconds - if self._seconds is not None and other_has_seconds: - self._seconds = max(0, self._seconds + other._seconds) + other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) + if isinstance(self._time, _Seconds) and other_is_seconds: + self._time = _Seconds(max(0, self._time.value + other._time.value)) return self - if self._seconds is not None: - self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other)) + if isinstance(self._time, _Seconds): + self._time = _Seconds(max(0.0, self._time.value + self._get_other_as_seconds(other))) return self - self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) + self._time = _FrameNumber(max(0, self._time.value + self._get_other_as_frames(other))) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -574,41 +581,43 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) - if self._timecode and other_has_timecode: - if self._timecode.time_base != other._timecode.time_base: + if isinstance(self._time, Timecode) and other_is_timecode: + if self._time.time_base != other._time.time_base: raise ValueError("timecodes have different time bases") - self._timecode = Timecode( - pts=max(0, self._timecode.pts - other._timecode.pts), - time_base=self._timecode.time_base, + self._time = Timecode( + pts=max(0, self._time.pts - other._time.pts), + time_base=self._time.time_base, ) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if self._timecode or other_has_timecode: - timecode: Timecode = self._timecode if self._timecode else other._timecode - seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds - self._timecode = Timecode( + if isinstance(self._time, Timecode) or other_is_timecode: + timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time + seconds: float = ( + self._get_other_as_seconds(other) + if isinstance(self._time, Timecode) + else self.seconds + ) + self._time = Timecode( pts=max(0, timecode.pts - round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._seconds = None self._rate = None - self._frame_num = None return self - other_has_seconds = isinstance(other, FrameTimecode) and other._seconds - if self._seconds is not None and other_has_seconds: - self._seconds = max(0, self._seconds - other._seconds) + other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) + if isinstance(self._time, _Seconds) and other_is_seconds: + self._time = _Seconds(max(0, self._time.value - other._time.value)) return self - if self._seconds is not None: - self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other)) + if isinstance(self._time, _Seconds): + self._time = _Seconds(max(0.0, self._time.value - self._get_other_as_seconds(other))) return self - self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) + self._time = _FrameNumber(max(0, self._time.value - self._get_other_as_frames(other))) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -620,7 +629,9 @@ def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi # need to use relevant property instead. def __int__(self) -> int: - return self._frame_num + if isinstance(self._time, _FrameNumber): + return self._time.value + return self.frame_num def __float__(self) -> float: return self.seconds @@ -629,21 +640,21 @@ def __str__(self) -> str: return self.get_timecode() def __repr__(self) -> str: - if self._timecode: - return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" - if self._seconds is not None: - return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._rate}]" - return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._rate}]" + if isinstance(self._time, Timecode): + return f"{self.get_timecode()} [pts={self._time.pts}, time_base={self._time.time_base}]" + if isinstance(self._time, _Seconds): + return f"{self.get_timecode()} [seconds={self._time.value}, fps={self._rate}]" + return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]" def __hash__(self) -> int: - if self._timecode: - return hash(self._timecode) - return self._frame_num + if isinstance(self._time, Timecode): + return hash(self._time) + return self.frame_num def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): - if self._timecode: + if isinstance(self._time, Timecode): # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses # frame numbers with timestamps to convert to a non-frame based way of temporal # logic and instead use seconds-based. From 98f4be6c46fdb0367c8c415e3177848ebbea0b9c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 5 Oct 2025 00:12:17 -0400 Subject: [PATCH 025/163] [build] Drop Python 3.7 and macos 13 support. The macos13 builder will be retired soon so remove it early to avoid breaking the build. Python 3.7 is not supported on all builders already and has been EOL for > 2 years now. --- .github/workflows/build.yml | 16 +++------------- scenedetect/backends/moviepy.py | 2 +- scenedetect/common.py | 5 +++++ setup.cfg | 3 +-- tests/test_cli.py | 4 ++-- website/pages/changelog.md | 10 +--------- website/pages/download.md | 2 +- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52ef6876..d04800dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,21 +26,11 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-13, macos-14, ubuntu-22.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] - exclude: - # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. - - os: macos-14 - python-version: "3.7" - # ubuntu 24+ does not have Python 3.7 - - os: ubuntu-latest - python-version: "3.7" - + os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] env: # Version is extracted below and used to find correct package install path. scenedetect_version: "" - # Setuptools must be pinned for the Python 3.7 builders. - setuptools_version: "${{ matrix.python-version == '3.7' && '==62.3.4' || '' }}" steps: - uses: actions/checkout@v4 @@ -61,7 +51,7 @@ jobs: - name: Install Dependencies run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools${{ env.setuptools_version }} + python -m pip install --upgrade pip build wheel virtualenv setuptools pip install -r requirements_headless.txt --only-binary av,opencv-python-headless - name: Install MoviePy diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 0de86013..14758952 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -116,7 +116,7 @@ def duration(self) -> ty.Optional[FrameTimecode]: @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - # TODO: Use cached_property once Python 3.7 support is deprecated. + # TODO: Use cached_property. if self._aspect_ratio is None: # MoviePy doesn't support extracting the aspect ratio yet, so for now we just fall # back to using OpenCV to determine it. diff --git a/scenedetect/common.py b/scenedetect/common.py index 66d60e2f..4bf3493f 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -352,6 +352,11 @@ def get_seconds(self) -> float: ) return self.seconds + # TODO(https://scenedetect.com/issue/168): We should remove `nearest_frame` if possible, it + # assumes constant framerate and causes more problems than it solves. Setting it to False makes + # test_cli_load_scenes_with_time_frames in test_cli.py fail due to differences in end time. + # We may also just need to clamp end time to the one specified by the user, this may not be + # happening in the code. def get_timecode( self, precision: int = 3, use_rounding: bool = True, nearest_frame: bool = True ) -> str: diff --git a/setup.cfg b/setup.cfg index 53d0f0cb..e966ab12 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,6 @@ classifiers = Intended Audience :: System Administrators Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -50,7 +49,7 @@ packages = scenedetect.backends scenedetect.detectors scenedetect.output -python_requires = >=3.7 +python_requires = >=3.8 [options.extras_require] opencv = opencv-python diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ba8da2e..29fd2738 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -630,7 +630,7 @@ def test_cli_backend_unsupported(): ) -def test_cli_load_scenes(): +def test_cli_load_scenes_options(): """Ensure we can load scenes both with and without the cut row.""" assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes") == 0 assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 @@ -648,7 +648,7 @@ def test_cli_load_scenes(): assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 -def test_cli_load_scenes_with_time_frames(): +def test_cli_load_scenes_output(): """Verify we can use `load-scenes` with the `time` command and get the desired output.""" scenes_csv = """ Scene Number,Start Frame diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6bc47db4..92ed8226 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -671,20 +671,15 @@ Development PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. -Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. - -### CLI Changes +Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.8**. ### CLI Changes - [feature] [WIP] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command - ### API Changes -#### Breaking - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` @@ -707,9 +702,6 @@ Although there have been minimal changes to most API examples, there are several * Remove `advance` parameter from `VideoStream.read()` * Remove `SceneDetector.stats_manager_required` property, no longer required * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) - - #### General - * Deprecated functionality preserved from v0.6 now uses the `warnings` module * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods * Add new `Timecode` type to represent frame timings in terms of the video's source timebase diff --git a/website/pages/download.md b/website/pages/download.md index e356c800..ea8641f1 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -3,7 +3,7 @@ PySceneDetect is completely free software, and can be downloaded from the links below. See the [license and copyright information](copyright.md) page for details. If you have trouble running PySceneDetect, ensure that you have all the required dependencies listed in the [Dependencies](#dependencies) section below. -PySceneDetect requires at least Python 3.7 or higher. +PySceneDetect requires at least Python 3.8 or higher. ## Install via pip       From 85d79b633c11590afb0aebd5b39bdc5d71af32d7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 11 Nov 2025 23:08:44 -0500 Subject: [PATCH 026/163] [docs] Place AdaptiveDetector first in listing This is now the default so we place it first accordingly. --- docs/api.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index bcf9fd96..76cff84d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,12 +9,12 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.detectors 🕵️ `: detection algorithms: + * :mod:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes + * :mod:`ContentDetector `: detects fast cuts using weighted average of HSV changes * :mod:`ThresholdDetector `: finds fades in/out using average pixel intensity changes in RGB - * :mod:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes - * :mod:`HistogramDetector ` finds fast cuts using HSV histogram changes * :mod:`HashDetector `: finds fast cuts using perceptual image hashing From 78d7fa4ea6cbf53d2baef43d47b69f566bbb64c5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 18 Jan 2026 19:32:33 -0500 Subject: [PATCH 027/163] [dist] Update installer license --- appveyor.yml | 4 ++-- dist/installer/license65.dat.enc | Bin 416 -> 416 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index af16f038..73a245c4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,9 +17,9 @@ environment: - PYTHON: "C:\\Python313-x64" # Encrypted AdvancedInstaller License ai_license_secret: - secure: of3o1pInqCJYwKLFsiadbsRYazCmCuZq7r2roaYvYXmBvm6e6JHsRU47waylTmhm + secure: QRCPoNYF1nqgXDn7pHgBzg== ai_license_salt: - secure: +NKWwlkEptlThgfeL35pLo7EsnkJc+4WODm8tTg1aO5fc0duQ4r100fHQYj6nzhyUdy3Dhs/mOLkxD8rNbBiEQ== + secure: +Gy+SRk8JUsaM+5pMEKITiJxdLilrxHpkKlrZzR3C9DPwdgYLGxt5sJn6uXuAJg7e6JsKHcT7tRks/HcSKkHPw== ffmpeg_version: "8.0" # SignPath Config for Code Signing diff --git a/dist/installer/license65.dat.enc b/dist/installer/license65.dat.enc index 9288aaa8979170348a9bbd639446d6e0e0c39d12..d3d500e1deb62f05749c08ae854bd0316fafa8cb 100644 GIT binary patch literal 416 zcmV;R0bl-a#}mx1%5sc6dL%54G6Op^B+T4%iKT#*t3J4MGjsVU7|sacBhX`lNc#Qs zH;2*KlW*TW)g#RhV29~I|G6S|-!5Zd)G^`XlQUaK`!J zF0A`-2f&x{koa2Wzv1y5s)3caCfyTAAv7W!;teqhWoZ>>u(qunb>zO#!0IfmzFK5_16@hOv)vNTV7Yy^u$ET%s-K>2sb4aYp z9qosKTQuD*DGW~z)$>!~UiNRi4x)0`TH~h-&P!c#3wkoOGDl$a7b!RS{!t=7o08Wmd5&h_3{LJGRz3IN z;zAb-qwr31L9@lM-M_7?9mb{1)8yP=q<~G{ss(VNI1YiC4!=X1YvPS2aBEN*JTFPrO{pBSRikT z8?iOfizfG+K-MMYxFtxt*~bD~CE*9Wn8}?v=PK>7WCkO8(;@0WITYe)qsxIzVD~OT<2?IeL284*v zB*>zYGT%qH2c@wHk!#oUCl{z6{*zdnzD8dKk4xnYv?13x=0unypjge_7aq+=#qzj1 z$Y`dK=0~aBWRuYw8X=9G|F^Kn0?@n0itHNe1zq|(AJVc<4ZixZh}~k&_iSl9mDzg} KL}oOSvplSj!O*|} From e2c11c9b9526299605700fa7baf854597f74a3b4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 19:51:20 -0500 Subject: [PATCH 028/163] [timecode] Expand PTS support #168 Allows much more tests to pass now, down to just a few. --- scenedetect/backends/opencv.py | 9 ++++- scenedetect/backends/pyav.py | 9 +++-- scenedetect/common.py | 61 ++++++++++++++++++++++++---------- scenedetect/scene_manager.py | 2 +- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 24d7d3c5..298f0301 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -209,7 +209,14 @@ def position(self) -> FrameTimecode: # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or # add a config option before landing this. if _USE_PTS_IN_DEVELOPMENT: - return FrameTimecode(timecode=self.timecode, fps=self.frame_rate) + timecode = self.timecode + # If PTS is 0 but we've read frames, derive from frame number. + # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. + if timecode.pts == 0 and self.frame_number > 0: + time_sec = (self.frame_number - 1) / self.frame_rate + pts = round(time_sec * 1000) + timecode = Timecode(pts=pts, time_base=Fraction(1, 1000)) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) if self.frame_number < 1: return self.base_timecode return self.base_timecode + (self.frame_number - 1) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index b6537774..8692cdb5 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -182,11 +182,11 @@ def position(self) -> FrameTimecode: This can be interpreted as presentation time stamp, thus frame 1 corresponds to the presentation time 0. Returns 0 even if `frame_number` is 1.""" + if self._frame is None: + return self.base_timecode if _USE_PTS_IN_DEVELOPMENT: timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) return FrameTimecode(timecode=timecode, fps=self.frame_rate) - if self._frame is None: - return self.base_timecode return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate) @property @@ -205,9 +205,8 @@ def frame_number(self) -> int: if self._frame: if _USE_PTS_IN_DEVELOPMENT: - return FrameTimecode( - round(self._frame.time * self.frame_rate), self.frame_rate - ).frame_num + # frame_number is 1-indexed, so add 1 to the 0-based frame position. + return round(self._frame.time * self.frame_rate) + 1 return self.position.frame_num + 1 return 0 diff --git a/scenedetect/common.py b/scenedetect/common.py index 4bf3493f..e4ab7e48 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -252,18 +252,22 @@ def frame_num(self) -> ty.Optional[int]: stacklevel=2, category=UserWarning, ) - # We can calculate the approx. # of frames by taking the presentation time and the - # time base itself. - (num, den) = (self._time.time_base * self._time.pts).as_integer_ratio() - return num / den + # Calculate approximate frame number from seconds and framerate. + if self._rate is not None: + return round(self._time.seconds * float(self._rate)) + # No framerate available - return estimate based on time. + return round(self._time.seconds) if isinstance(self._time, _Seconds): return self._seconds_to_frames(self._time.value) return self._time.value @property - def framerate(self) -> float: + def framerate(self) -> ty.Optional[float]: """The framerate to use for distance between frames and to calculate frame numbers. - For a VFR video, this may just be the average framerate.""" + For a VFR video, this may just be the average framerate. Returns None if framerate + is unknown (e.g. when working with pure Timecode representations).""" + if self._rate is None: + return None return float(self._rate) @property @@ -499,6 +503,9 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return False if _compare_as_fixed(self, other): return self.frame_num == other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num == other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -508,6 +515,9 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return True if _compare_as_fixed(self, other): return self.frame_num != other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num != other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) @@ -515,6 +525,9 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num < other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num < other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) @@ -522,6 +535,9 @@ def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num <= other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num <= other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) @@ -529,6 +545,9 @@ def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num > other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num > other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) @@ -536,6 +555,9 @@ def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num >= other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num >= other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) @@ -565,7 +587,9 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT pts=max(0, timecode.pts + round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._rate = None + # Preserve rate if available from self or other. + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate return self other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) @@ -610,7 +634,9 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT pts=max(0, timecode.pts - round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._rate = None + # Preserve rate if available from self or other. + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate return self other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) @@ -652,21 +678,20 @@ def __repr__(self) -> str: return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]" def __hash__(self) -> int: - if isinstance(self._time, Timecode): - return hash(self._time) + # Use frame_num for consistent hashing regardless of internal representation. + # This ensures that FrameTimecodes representing the same frame have the same hash, + # enabling proper dictionary lookups in StatsManager. return self.frame_num def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): - if isinstance(self._time, Timecode): - # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses - # frame numbers with timestamps to convert to a non-frame based way of temporal - # logic and instead use seconds-based. - if _USE_PTS_IN_DEVELOPMENT and other == 1: - return self.seconds - raise NotImplementedError() - return float(other) / self._rate + # Convert frame number to seconds using framerate. + if self._rate is None: + raise NotImplementedError( + "Cannot convert frame number to seconds without framerate" + ) + return float(other) / float(self._rate) if isinstance(other, float): return other if isinstance(other, str): diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index f4982ef8..8b47b32b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -404,7 +404,7 @@ def _process_frame( for cut in cuts: for position, frame in self._frame_buffer: if cut == position: - callback(frame, position) + callback(frame, int(position)) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: From 75243b026465bea7b2af007be40654457e423c81 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 19:55:18 -0500 Subject: [PATCH 029/163] [general] Add `strict=True` to all zip() calls as per new ruff lint --- benchmark/autoshot_dataset.py | 2 +- benchmark/bbc_dataset.py | 2 +- scenedetect/detectors/content_detector.py | 3 +- scenedetect/detectors/transnet_v2.py | 213 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 scenedetect/detectors/transnet_v2.py diff --git a/benchmark/autoshot_dataset.py b/benchmark/autoshot_dataset.py index 5312704e..80a58da1 100644 --- a/benchmark/autoshot_dataset.py +++ b/benchmark/autoshot_dataset.py @@ -17,7 +17,7 @@ def __init__(self, dataset_dir: str): self._scene_files = [ file for file in sorted(glob.glob(os.path.join(dataset_dir, "annotations", "*.txt"))) ] - for video_file, scene_file in zip(self._video_files, self._scene_files): + for video_file, scene_file in zip(self._video_files, self._scene_files, strict=True): video_id = os.path.basename(video_file).split(".")[0] scene_id = os.path.basename(scene_file).split(".")[0] assert video_id == scene_id diff --git a/benchmark/bbc_dataset.py b/benchmark/bbc_dataset.py index 1bb7693e..5feb54ae 100644 --- a/benchmark/bbc_dataset.py +++ b/benchmark/bbc_dataset.py @@ -18,7 +18,7 @@ def __init__(self, dataset_dir: str): file for file in sorted(glob.glob(os.path.join(dataset_dir, "fixed", "*.txt"))) ] assert len(self._video_files) == len(self._scene_files) - for video_file, scene_file in zip(self._video_files, self._scene_files): + for video_file, scene_file in zip(self._video_files, self._scene_files, strict=True): video_id = os.path.basename(video_file).replace("bbc_", "").split(".")[0] scene_id = os.path.basename(scene_file).split("-")[0] assert video_id == scene_id diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 6dd2355e..6cf757fa 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -173,7 +173,8 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr ) frame_score: float = sum( - component * weight for (component, weight) in zip(score_components, self._weights) + component * weight + for (component, weight) in zip(score_components, self._weights, strict=True) ) / sum(abs(weight) for weight in self._weights) # Record components and frame score if needed for analysis. diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py new file mode 100644 index 00000000..752749cd --- /dev/null +++ b/scenedetect/detectors/transnet_v2.py @@ -0,0 +1,213 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +""":class:`TransnetV2Detector` uses a pretrained neural network. + +This detector is available from the command-line as the `detect-transnetv2` command. +""" + +import typing as ty +import warnings +from enum import Enum +from logging import getLogger +from pathlib import Path + +import cv2 +import numpy as np + +from scenedetect.common import FrameTimecode, Timecode +from scenedetect.detector import FlashFilter, SceneDetector + +logger = getLogger("pyscenedetect") + + +class Detector: + def __init__(self, threshold: float, flash_filter: FlashFilter): + self.i = 0 + self.y_prev = 0 + self.threshold = threshold + self.flash_filter = flash_filter + + def push(self, ys: np.ndarray, ts: np.ndarray): + predictions = (ys > self.threshold).astype(np.uint8) + + cuts = [] + for y, t in zip(predictions, ts, strict=True): + if self.y_prev == 0 and y == 1 and self.i > 0: + cuts.append(t) + self.y_prev = y + self.i += 1 + + return cuts + + +class Predictor: + def __init__( + self, + model_path: ty.Union[str, Path], + flash_filter: FlashFilter, + onnx_providers: ty.Union[ty.List[str], None], + threshold, + ): + import onnxruntime as ort + + ort.set_default_logger_severity(3) + + if onnx_providers is None: + onnx_providers = ort.get_available_providers() + + sess_opt = ort.SessionOptions() + sess_opt.log_severity_level = 3 + + self.session = ort.InferenceSession(model_path, sess_opt=sess_opt, providers=onnx_providers) + + self.pixels = None + self.time = None + + self.det = Detector(threshold, flash_filter) + + def _inference(self, pixels: np.ndarray, time: np.ndarray): + pred = np.array(self.session.run(["output"], {"input": pixels}))[0] + + cuts = [] + for i in range(pred.shape[0]): + cuts.extend(self.det.push(pred[i, 25:75, 0], time[i, 25:75])) + return cuts + + def push(self, pixels: np.ndarray, time: np.ndarray): + if self.pixels is None: + self.pixels = pixels + self.time = time + + return self._inference( + np.stack( + ( + np.tile(np.expand_dims(pixels[0], axis=0), (100, 1, 1, 1)), + np.concatenate( + ( + np.tile(np.expand_dims(pixels[0], axis=0), (25, 1, 1, 1)), + pixels[:75], + ), + 0, + ), + ) + ), + np.stack( + ( + np.tile(np.expand_dims(time[0], axis=0), (100,)), + np.concatenate( + (np.tile(np.expand_dims(time[0], axis=0), (25,)), time[:75]), 0 + ), + ) + ), + ) + else: + c1 = self.pixels + c2 = pixels + + t1 = self.time + t2 = time + + self.pixels = pixels + self.time = time + + return self._inference( + np.stack( + (np.concatenate((c1[25:], c2[:25]), 0), np.concatenate((c1[75:], c2[:75]), 0)) + ), + np.stack( + (np.concatenate((t1[25:], t2[:25]), 0), np.concatenate((t1[75:], t2[:75]), 0)) + ), + ) + + +class TransnetV2Detector(SceneDetector): + def __init__( + self, + model_path: ty.Union[str, Path] = "tests/resources/transnetv2.onnx", + onnx_providers: ty.Union[ty.List[str], None] = None, + threshold: float = 0.5, + min_scene_len: int = 15, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, + ): + super().__init__() + + self.px = np.zeros((2, 100, 27, 48, 3), dtype=np.uint8) + self.time = np.zeros((2, 100), dtype=np.int64) + + self.blank = np.zeros(self.px.shape[2:], dtype=np.uint8) + + self.i = 0 + self.j = 0 + + self.predictor = Predictor( + model_path=model_path, + flash_filter=FlashFilter(mode=filter_mode, length=min_scene_len), + onnx_providers=onnx_providers, + threshold=threshold, + ) + # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling + # `min_scene_len` which should be specified in seconds, not frames. + self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) + + def mk_ft(self, pts: int): + # t = Timecode(pts=pts, time_base=self.time_base) + t = float(pts * self.time_base) + return FrameTimecode(t, fps=self._fps) + + def process_frame( + self, timecode: FrameTimecode, frame_img: np.ndarray + ) -> ty.List[FrameTimecode]: + """Process the next frame.""" + + self.time_base = timecode.time_base + self._fps = timecode._rate + + pixels = cv2.resize(frame_img, (48, 27), interpolation=cv2.INTER_AREA) + + self.px[self.j, self.i] = pixels + self.time[self.j, self.i] = timecode.pts + self.i += 1 + + if self.i >= 100: + cuts = self.predictor.push(self.px[self.j], self.time[self.j]) + self.j = 1 - self.j + self.i = 0 + + filtered_cuts = [] + for cut in cuts: + filtered_cuts += self._flash_filter.filter(self.mk_ft(cut), True) + return filtered_cuts + else: + return [] + + def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + """Writes a final scene cut if the last detected fade was a fade-out.""" + + cuts = [] + + last_time = timecode.pts + blank_frame = self.blank[:] + + self.px[self.j, self.i :] = blank_frame + self.time[self.j, self.i :] = last_time + cuts.extend(self.predictor.push(self.px[self.j], self.time[self.j])) + + self.j = 1 - self.j + + self.px[self.j, :] = blank_frame + self.time[self.j, :] = last_time + cuts.extend(self.predictor.push(self.px[self.j], self.time[self.j])) + + filtered_cuts = [] + for cut in cuts: + filtered_cuts += self._flash_filter.filter(self.mk_ft(cut), True) + return filtered_cuts From d75833fb2bb1ff70ea48a8841a05de6c65a7aff8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 20:06:46 -0500 Subject: [PATCH 030/163] [dist] Bump minimum supported Python version to 3.10 3.9 is the last EOL build and only gets a small number (< 100 per day). --- .github/workflows/build.yml | 2 +- setup.cfg | 6 +++--- website/pages/changelog.md | 2 +- website/pages/download.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d04800dc..957dfce8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] env: # Version is extracted below and used to find correct package install path. scenedetect_version: "" diff --git a/setup.cfg b/setup.cfg index e966ab12..c01def6e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,8 +23,6 @@ classifiers = Intended Audience :: System Administrators Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 @@ -49,7 +47,7 @@ packages = scenedetect.backends scenedetect.detectors scenedetect.output -python_requires = >=3.8 +python_requires = >=3.10 [options.extras_require] opencv = opencv-python @@ -67,3 +65,5 @@ test = pytest [tool:pytest] addopts = --verbose python_files = tests/*.py +filterwarnings = + ignore:TODO.*Update caller to handle VFR:UserWarning diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 92ed8226..e9da750c 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -671,7 +671,7 @@ Development PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. -Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.8**. +Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.10**. ### CLI Changes diff --git a/website/pages/download.md b/website/pages/download.md index ea8641f1..ffa78d12 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -3,7 +3,7 @@ PySceneDetect is completely free software, and can be downloaded from the links below. See the [license and copyright information](copyright.md) page for details. If you have trouble running PySceneDetect, ensure that you have all the required dependencies listed in the [Dependencies](#dependencies) section below. -PySceneDetect requires at least Python 3.8 or higher. +PySceneDetect requires at least Python 3.10 or higher. ## Install via pip       From 34dd1dec2c2a22917af926301b0d52695c62aa1e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 16 Feb 2026 21:59:39 -0500 Subject: [PATCH 031/163] [dist] Add new project logo and update icon --- dist/pyscenedetect.ico | Bin 410598 -> 876 bytes dist/pyscenedetect.svg | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 dist/pyscenedetect.svg diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index b52baa836ba7ca1dff483e4a112a84ef96e495fd..f902e72ebe63a9dd26677f739c639e7f8cf50fde 100644 GIT binary patch literal 876 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 Q1GWRU1GWSI@jLMU0cE-|{Qv*} diff --git a/dist/pyscenedetect.svg b/dist/pyscenedetect.svg new file mode 100644 index 00000000..9f4f6a89 --- /dev/null +++ b/dist/pyscenedetect.svg @@ -0,0 +1,52 @@ + + + + + + + + + + From ee95841ad19f81523831e223ee42601848351134 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Feb 2026 21:31:28 -0500 Subject: [PATCH 032/163] [dist] Add script to generate icon file from SVG --- dist/generate_ico.py | 90 +++++++++++++++++++++++++++++++++++++++++ dist/pyscenedetect.ico | Bin 876 -> 21797 bytes dist/pyscenedetect.svg | 15 +++---- 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 dist/generate_ico.py diff --git a/dist/generate_ico.py b/dist/generate_ico.py new file mode 100644 index 00000000..c18d57b7 --- /dev/null +++ b/dist/generate_ico.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +"""Generate pyscenedetect.ico from pyscenedetect.svg. + +Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). +""" + +import contextlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageFilter + +# Different raster sizes to include in the ICO file. +SIZES = [16, 24, 32, 48, 64, 128, 256] + +# Sharpen smaller sizes to improve ledgibility. +SHARPEN_AMOUNT = { + 16: 200, + 24: 200, + 32: 100, + 48: 50, + 64: 50, +} + +DIST_DIR = Path(__file__).resolve().parent +SVG_PATH = DIST_DIR / "pyscenedetect.svg" +ICO_PATH = DIST_DIR / "pyscenedetect.ico" + + +def find_inkscape() -> str: + """Find the Inkscape executable.""" + inkscape = shutil.which("inkscape") + if inkscape: + return inkscape + # Common Windows install path + candidate = Path(r"C:\Program Files\Inkscape\bin\inkscape.exe") + if candidate.exists(): + return str(candidate) + print("Error: Inkscape not found. Please install it or add it to PATH.", file=sys.stderr) + sys.exit(1) + + +def render_svg(inkscape: str, svg: Path, output: Path, size: int): + """Render an SVG to a PNG at the given size using Inkscape.""" + subprocess.run( + [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(size), "-h", str(size)], + check=True, + capture_output=True, + ) + + +def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: + """Render the SVG at all icon sizes, applying sharpening where configured.""" + images = [] + for size in SIZES: + png_path = work_dir / f"icon_{size}.png" + print(f" Rendering {size}x{size}...") + render_svg(inkscape, SVG_PATH, png_path, size) + img = Image.open(png_path).copy() + if size in SHARPEN_AMOUNT: + img = img.filter(ImageFilter.UnsharpMask(radius=0.5, percent=SHARPEN_AMOUNT[size], threshold=0)) + print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + img.save(png_path) + images.append(img) + return images + + +def main(): + persist_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else None + if persist_dir: + persist_dir.mkdir(parents=True, exist_ok=True) + print(f"Persisting PNGs to: {persist_dir}") + + inkscape = find_inkscape() + print(f"Using Inkscape: {inkscape}") + print(f"Input SVG: {SVG_PATH}") + + ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() + with ctx as work: + images = render_all_sizes(inkscape, Path(work)) + images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) + + print(f"Output ICO: {ICO_PATH}") + + +if __name__ == "__main__": + main() diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index f902e72ebe63a9dd26677f739c639e7f8cf50fde..608b234438e8e606080f16cef1d9abb77e6fd7a1 100644 GIT binary patch literal 21797 zcmagFQ*>s*7B2e7wmY_M+qP}n>^SM5W81cEJL%ZAt&_d?eZ1qG^{`gWnzJy*`f94G z001BW1OO2c@beG=vVZ{qx1SOw=KpA0Pyhf0KN8S=MCs@I zpBW%R{`3#PzyCG_0Dw&-MFdqmGLsV4rBxJBhy9&-qDOASQzn7>^t>(zFMx#uB@+H| zY&pmXCsZ=hc-0XEe<4#N&5bFb&{HG^zb*zLU={xk-3dKL<{0Q=Op z^#|P(EWp^?HW<0_&}LPKS%ox0nu3dUcz zK_k*O;`|WEFt4>yD=~Q)Ml#vNuQGTHbUu+KR9qir`S}#i73CAH$iFu1a+EDEPmd^0 z){E@@Vjkt)aeZ83Ml<$(ELaPGMCz5oEAEdK*%Q?kEIqASjb z{<8zlh$9~PoFmCFj#=c2n)W#YW@$jcuhbQte^yOa2WG3N38u7R&PLXU_z~7$$8Q7p zh9rpXLZkQ*{D>|&MyGzT4N=$M>H8{4<)+ z|2i|F@*}>1`iYpTm`T}TL3EcOL4!`xV+Ia;PhG}Y|I~gI%H$xi$Pzp72V7UjcJTTZ z7SioZP1!3h=!mZ^NJD}H7x1^p9VhnXN)aFu5K{gQ+)FB?mo+y$BU)SM)=Y25L>nEQ z*t=_!<`An-4{MgAW!o?~J zhiZ15UtWy*jmRJXSNh`X*WS@SC(Pw}u2%Q$Kx#`Hjao&Y|E0m(v)L(X(wKEtQ*k>; zf&?9Wa`~FH2|+zNqb2w4@Hbe5zP&n@KF;4DU~x#k-o_Q)v*PX+QE+p}OiGVtH)Q!= zo#p;|Cbw@)=AupW#fFrzFWTlXq8@4b8ugTpUAxH86EEy1qev!u%&3HR>l~Oh8|3}s z`jd^htrt4Ko^=t2Gi@3*(~7$Ve@~;1@klXKJ+#xRdbhCM+vaR(6<6P?vaF6}cjSQK z$d!vh!)mRj?LIU;n9sA1&8F)3nJH(z;%A@Vxb#F?0!x^#X^{xPoO`W|TW{nv0S@*B z+@T$Z<3i5n={A^5CJ&14&V$#6`ZM)Vbk)Sfl!|fKdjWgN?dqk`M99HNUeCywc(Gw-&Znbe(RiH zP=R?2iv6F%x5INBD~`$dHp=(`OtxY6)rX6nUIL7n>;nyBkh8zPy1Uxxjw8#wQESr3#6pS=J69#7a560+V5AIdEd)j-f+6VaOw} zUd@Kn%kNHbYETg)Q%~|C801f5oMYt%%Y+g%L!QG8e?L5}C^FLEl~1D@!7e?FJfadH zCP03;Lj~}CT=O!BH*#hy?hM3nqou|~hQltv5sS$%5E+E_LsoRx->Rxr>GRrU%YYRZ zT{fRUg&@FaRB6~MX7(&m;`7Dt2&UP&!yd?R`FEhukeR0n=&`g_W50a*y|QH>p@Oxn zM=zn%r7V3|6K;R0g?Pw+OpcQG#^18fzn^xW7@+>x*#E1tA^2ev{8!mHdwD$o0ATa~ zQ#M^G-ZrSJKbDZ0yP?DPqB-A|sEi(wk_x!WS1A%9kYLfsnwlsaQ&e0%`PmOR6}U?i^GrXNRbCbx@57}+Y(c2mC`Qi4VK7N%kmig+e+2Kn|om{!oMZ3Q8I(JOt zeJ|?dbf=&?B$<*=0UyHTI!rUCPbWL!BC{|5l)y@uanX-cUo+Dm@~2lG=pU`V_(C6k ztFIdr^YLUpkZZw+!^hx^PPP}6Z5{lRe^X90H9PVdq+5yL zDJ21Nj6o`ydaN9I2SdsQ)T&N;{BT_Yt&Mj@D2O)#gXJ5E)Pz)-eW^QSEO<=(jA90`=!wob;Zl!N{voxe1BkM7$rq&Z%NJtf$d6>j^~g=e!qi= zU&Fa|cX?GoRTJ9geqYsTX?q3t1#h0~<|vMSPR^kS06Vz+ZnIm=&0k4+D%9Px(1;Mb z+1AK=$}OH=;%F_3ab+cH8dFx+7I&$}e$T*{7#g}=d!(rChMZiGzwi_KZ!PQuRICDM zNCHfo=Nd_~od>omcc-r;&^*i(;XEePZ$Kp1l!PeJ4ZrtAMF#DU5 zF_P@M6ucfJLzg{1h;3(L{ir6_+|1}W9xapR1hc+XwL3^p8$1et6sBlu#)2_x2s)Avhf1W86{4pA>QCB&UyO5FQB4No90BRVoh zY(Eb`&-3cjvde|*+g|u}uEEy!j^C4GDY`JD6v~_1ez4!a%E2|)lKs7|5u>8Z+cdsK zIs3-rsTEw#wJeZ(?0sOiY#oDl!ZOP5Uh^H+Qx@14prbX(Ck<*dr@i?qVDAOa4*W@} z0HGV;n@pu3KQ)G8SIZl|IZ}5VDM4}Jq6n3jEgEo%2pt-YIepin#t|0zJAh}A3;|0> zhV8BA93(QIgA6u}(A-_RgT{7;O3CG4kEM7WO~-Pc=sBgpva6YVTF9pP$S;b~dm*1a zEC}QxO}~P%-a12L{(bWAL+N`H~K0ix)Dc?kgU{WWKi(7^M4-dgAUHig=u z8%>4$@r^URr+L4hKeu1Mov%Vb0zCg@YZFGsECXZ)XKR%NXFHRe4b;)upHC-M@ z$@!HbP231j#-w(?u0+Y~fx3{K7!vMdHfBt0xNDZ-s#A zEi%=NH@LYCrKE&9GGR7K*xxxmmX`C~T#VW<&KXFy4~oqJlBd-M?P|&HWtg2bN-#2< zYH5!SJnC3oF#*VL4_--{}hMqxd2kZSb5DdMut(|A3~0pX1J^^GRZx!YTHgvIodJ$k1DPWvG8lBA@pOdRD5bO+*%l4-*Zk;5rPri)Y%XEEhbRJS91POXs?ncq6X_V>@}^0_I_2 zKs=?!Aa=AOLWOU&=?F$}#}9DKj0;c2;m6X`(>r*v*MEg8r?OW_e1j8+LzRdeudk^= zkcm1>3bbrwI@h705x93RI?A1eP&ehMKJN~bOFm(!h{V7graTE53tPfQ zmK9kKjf#PqccFxJMB{-2wSa7Kk(5up(%{N&Rc=LK0UAFzcp@pGpY^+LUzlS50B<6s zr7t6V)~bmm#U})-*XExJf(w_~pUN^Iqhwiazo!6`5J(vnL9{mrHInWa2{34$5j&Fr@zF(L;UVoU56(Lbu{ z#Qmw1ZGp4Ou)32~Q4fwwB$@ZGX9tIC@zPG6h?g=b2GaB&)RP9-AO#*P_?^ONz*N>A zXgl0np-Bds%iZ%da_RPDKH&gq_N6Ki`#nBk4`xoJ#%LOHc3vaK;PK?sX(@`#o(Oc_ zox4Kf;I6ct8Yvp+Kap|Ugs7n&qUE*DMxuLDGt%7j>!uy}ReFPRfD5VlAF9B$OYy(I zj}^)9&W)Jw^rzi3kPZ`)uz<>qNy;$;*s1J$wG0Fmv(eB@lg@a#QvaP^Pw#GrZjurX zvt_iN%Ar8>&6N1y*ubi3x`!vSne)aK*W0ud6xOCfC$eq_77vp}0vo55n?cOkRC4dP z`#hOV;>#aN2^|0A1+Jmbpaa;TQ6d1XJkM%6?DPHA6%Fu7PT7e zQ%q`dKr|drsg2z5h)y3CE}RyEZB%8+DOhnECqkohNX=A7V_TDhJyBX#NXD7B^aI5rkWxcf3SfOyisde2q8{ee? zq4i}wVMgk&fd1kX65M38- zzka|fgTzCTDKzP7$U+7+<~5W89uAE?mpQJ!7tBF6Qljiqk&3DN%WRyK%s7x~E?|UW z#CRW}$ubAQ{haq?hC4>{mPI`u)KNwfGbhbgkFsFNyr%ibM4}{}Dgn(SHNdH9fGRafJ^K?Lynl}46p^mF+>ba>{hdji!ztpeteKv{K-s`zOJ~xdfGvrtm&EhIh~K+}uM3=Vr$>AqVh3Cs^>ay4Z7l%++`sxB|9vTN;7rSEcDz$=hu1{A`o;X+GoTrv98WAHB9V zq%wJ@gM6mS)Esp6HUUfw3Yo3Wswxq_02y@@Sl zm}m;(5wDw>RBCtBDw2A(6D>BwQBh)WUdrz&1d24=xK`)A3h<6hU}T6<41d`QYItDk zhvhzyIy7_x4ZVtD+F=11BM?)Ul_5(29ho?HTvg--X>cBOTaXlM8A!1a+Pxs6CkB%h zX<%2Cw(CR~hdC=|`Xe8jPppEti1C^g&GZjiqU<_ZK|fqnvHxN)c|lCfq6;FC#uWy# zO3?U+4a-oJ`j=xK)VUliZ#Pd1+Syp`^D%<~)5XU_3k7cu2EHWnZr(}ETQjaL^PRyN zrQY3)_;S^SBw8+T$;=csmox@WGU@Vo9BB#}kt7&Knny)loqMA}D2Il+ zuVM%lC>+G-+(+|aG|$0t0gqnT-Hf-+ZZnYb-fhrW;_8B*u@u^BC|qkG$@K<5E0vLc zX6<3STETz`_stJhN{1%bqVNU)nUsvc%r*)nT?+hLi)f#>N2mm=+u>ONl)=nDxO*Wy zdk**4^_R!$rQ0BEKp<&4J2E&)m3awyp_G2?2KV*=ih)-tBS zVB}EGz+?M4-$GuiIv7h66BI5l1NadtiVP+CASrCVf@?`3i;fAS(pA+4nFH6IeJ30D_9SljE%&SrFJb)^~mF%2Lo?mO*JdbS`eZW$%E z0pxSeyOcxQ2b{c{vj29&M@7HdXy~124iBuc(P^?5h@nWU6*EFQ{QhoiN@Bs(d_o~n zJv`DGEw$T#EIBDu)fJVctw&?v!;G73$sn zVqmGfqBe?bLn43#G^NeNP}0J{qpSI6ZeSE#YT0Nx3&g00g51(I{Tv zzZ^_R#PO8LvXEpLE^XxoT_HIvh_Y6Z5wOfPGhA@c748 zFI3Rxtrr1+enTmb3^0*l1%<-TL&GHnXy3q%&m``ed{IVa-2jC8g_|(dP;t29|Hxk3 z3hCNj(!y}-{xyYCK9(IYW+wACUOaW#O^D{cnOgR|P%JiK4vh&9gO+W(P>i4KXWg<9 zn7buHJwn?P(Id25+%_me$$4tmMy{d^2u}Of;To35n_-j!o|Wz9RgDDcfX^*FAt`7s zcKkioP;4;XN&do+9ceSuth>^5s@$rgI&nycQBwYQ)__K%9a6GD{EoH0J4}A1KP(<} z-Z^X&m}i<4vyeA~x|KU3|wW8ZNaE2tM2kIQd}SofD~4q}(kb1p>?_$VZumrQ|7xIE5L)f%Ksgam_^ z|IvXa>d_JLwFI7Ds~U*rKc}v(+}-|I*?d+6f(m+_c1VOM{Bz#YFbNCm0=S>$JJhEO zfmwCXvgMQzcL4+(YdSF z6SNvCD2$YyY+3c06dHA)P=w;T{P-KC#O;uUR*E;xZiGD@uPs~fBmBr6f7j%ro5nSR z$V0>qdXpth0?fC9a?nS?Yi0(eEGI5}O;WTNZO*q&;fvAe>@Udi&Fv(8gO-v^`Q2SY zX*&a;kWQF56EvpZZf{ zK9BJg6&l@J;0reIKVSm7m#%X1BrKX~^U1}qw8^}md|iHTqizYa^%49uidr2K$(QRr z^c^?m>u$sZ1OWqt?8g-I=q1Fp<^~Nw!BbZPvX81F2vK5_JD8KH{8{#L{&@9l8oLf$ z_QJ`@;_r1YBn<;+y~UFVt<(=iphZo89&{@lqUR8D|C133I`$Y}AnCBJLOEj3dr!Egkd)aOd9G!}z5igAkZ?106E~IqS%R7jS z;P%o@ZYn4Y?r6qkJNnwJ&(OK{cj>mouLMx#NX-mJ4pO?okGT0WQPI9DTkg0!1Ex2G z@czhU|CwdpE+A{Y__#VNxN>?`Z=jpFvHdybM)DFqs-W#xaq#gO3lyAjc$H7lr|W^h zcy`lDAD9qNuuhEIFp@#X8Tqng=$xhsW=9V0uqts;OafbWN#cjOJ0|^NU-@H^(`Z(Xk)WGAJ;XuYhnoBDdv77Je93@cW zf4_0WR`1E)Uh$gpJoXEzp?c1o<|Auh|N3hnHGA{#us?pAZ{&OofHBb{yi`OjQETC& z)LWDMnch{4%huWOTf*bo;lp$4yjHVn>pZv<)f}%&VcdCbvn*mhnZ%lMWXha+6?f_- z(Nx6R$6a0G@Sc_r%Ce_Q^{JTeXrZ8Py)Q<{=23rZa4c|-TRNyaJj6aO#gtH(?Zxjy zWczV>ym@L_VEGw?MKA&rPpA6musyc8+Lthw4a4vHX~w}}`S{`sH*OT>ZRlaZUQgT4 zyX`6{@ySjuxt?|9X5hJLOHe|b7BFf=cxAjxr{N-@Q1I|}#h`Pc;ECL; zJZ=Uw;P7}Wj@TqEwX*(8doKJXogEQ>XZ>)N9@I59bX_MQZ{m+TS#Yh+F-#(7WP=5S zeS@BCYJ%E*v`I{$maWe|yee;=rRS2F->1b)MEu-5OZflZAksceZgfzTAqRUwZKlvk zDh!FaC?g9^#aFsU*WB1HHz44G zpmhYaz)>8SsJT>v$<6#)iivlVRd<0ot$a*Ieo1ELCzyVAk-4_v*tpNS{^+^(Io6R(HgzE-B^`S7 zt2qBPdgla{3T=Q2ST=`8UV)hcR8I&%kx+W4Iy;-GsiA*XqLftt(MW1_^pY6z1^|e! z)Mn2Y$Np}O&B)PTz5FiQ$9J4q?lz47OaIGX0EI&E%goYgZ6^B`An(n`+TBq1OnySF zWhx;(iXuU+UhQIQ@^g zb^}y1-DvR{2G+d()H0LaL1s7}D4ZmAe{U@f;wR5Xbah>lDns`2@=icQ25hL9hsnK# z{SNa?NN;T(>le!q;1`_n{<8OlK#Yik6c~~Bn}L&)67T$CFDf^jATfvHy6zeSe0BuZ)md1=1X74RgmL-micx0a z)o^aNA8^r@0uc({AF(h|CAb_%0<~FkYb)vzpocdxKrY&A94sEIyFM@hvb8_5cQ-`M z>%?ma_^eIQ4k7~iDU_v%i`Qnast1dQ6zeg%>`_PDC*SVp=zTJzsAL%X-35$t4$* zcS6VF@tu8CR-eI=g@O@MvO;Zj@3W9c2oVW8?pZ)R5Dk$QJw;{W03_lEz1SWd&Q2Hi zn;x73<5@!5z!-8^3Ur|8_B%*V^#?)v340r=t4DBGH;p*E`+|6fm z1uF$>blZ!^0c-2)jyB(lnH*|?>dDgsoiAVxj&)l!c?m+A^QB!?vdf=bnGh$Fb>hnO z2w^jz_}IgDTpql~A;%*(=JQiUXe-^67|S(!>b{@H+4#R-R3pOE@Qfc(Qf_Fe@#k8v}(xPJyS~*#POO{U8euCjpYHC)ZEKL4=8vK)fovI6twzzUI*V1&m_P zipDC_M9ny8OK~(ZGm<&vymL@_(T>l?E8wWd_=IsGu5DdW{+{a{lZ_r7%sP=eayp&# zNB!m+|5NVJp0yV)02KA`pJbWV{3WrDDqeto&NkBv84)o6NUZqFJMh&5Gnqcv` zK>wuB3S9)Q>iFhDbaCZwV*!SCEBgwBkdX7bQcwz;Tm0HWSb5rx_V zA{W}&h9>j44n}N@X*~tsPs4%DIu1^F`pL8**u-PY?pH62m`na)ui|q8bx z%rZZkYiO*CLzt8_hzc6keW6bG?I zL_zRfPR~!e4`5wpfW<$+!esR#ux$cWU74ufvZAcc-bp3Ys~~$r;|&0)tZ9*XxmsU9ii997XCaR;bWMpR3_S_q@(hN!nCkA2o zr3=1udKmGyBm(n{G$;)Ap9^1+i07`Nc&1J*ZO_R4$>8hT{pi$aU)x&4%tvM?WRiY@ z7-3LzVyG&NDT-v3a?oT3;{pi!kZ8t6>l+7JT{FH3#tNnXUtMSGuiR1LkWk9YZNxjbUX0VHfDXyLee8$ zvNwy#`p&@35Re%Cp5gxq$1Za=9vzCE)gSt6TL}S^(~AUIomTi@k)mRGEiUQL!iaoD zx4zS=Ge%FKC)9o0n*c(z`!%zp6j4)oemeHAwJspgVDP=qZJ*rnN8j7`nKow2O+{@Y z(NK}ho2AGb3%Fe@WwmlVsni6FHh8T=bUvO@_@^yRD9 zV@1nP0dC(E=kl0S>F-4OCE^7H9RTqs9J2&!9&k9k;9cU-g55jT7w{LS6b0>|4G-U` z)si~T5Hfof#8YL2Sv0Cut?cBe6|lNV>_k8Px5?ul1pG?t49&*j7R)YguZ&!JQ4Ec{ zgAOFO@GHOM#ko?jd_2Fd8JN7Y!r_151r`F)c$!j6nZBy)%9_emOcjH|N@s3aW~jo4 z2kN>fC>#9qwE`WI$$F&4maLwbOdT2;=ywG4Adnu=&yucKQBmGdP72Cv01!qic9j%V zPl&X$Gh1p`HkVysbvA=a?=KokK3YiBV%xu}`W+WlM-75`$@`Y}tsH7Uki(XH@lrOO z<13w1?dg4URkPjDMpX==r7y19$&w>*=1V|kd@%uNU+P%6E3A&*22#B&{GI_%W?kem zmKehvuS-zR;|a*c#{#Ifx*d-49g0F7PiGD+{ZUt`@h^cYOuw^YVP)&HK7j1xxF&rd zWisKhVWJZ$B&_a|^}WO=t)j4Ab>050#}1%+JPVLYt&AQ}=`H&Qu&6gX%EV~giWgSS zR4=rCR+#bzbI+P7Ie@i4lont5oBMq8u=wo5iY@TGc>1t{6*;1WM4Ds=j3?)#ub_YJU#e%?yA&gl=YI|T@6aJe2Mb_ezaDOr>a zPnRre2KkMX=M(%s)4cwAA3mMw@wu&Z*Zlln2zH$8Q|6twf7sEEDZVbR{YG?8g%y|S zzjxKg*-M$}%@<%NlIiPjD*WHhr@@P{_>k?jKy}9E_BV#f(aGq7^#gK-W=79ZkIRKRro7L(Jnsj1Z`emrf#K$DKD=oc6Q&1(VdhmCkZ~9C@ zSkA2^*E*9ZRQZ-T`f^8i zFf@Tkvdsb-z3{b26l-lUo-xb8QeW#$X$9%H?-yXN2FT}7d+t>K`p4DNT)E$Jv87^`d37OUhvnU2oARjdRbti|8f3RS}nwS zv5`XNPZoUBNmk}HGssGtDFjT0N2Ql>)1F(|_7!)e^7gmKd>OU0`XKYI1(a5GllwB> z)$4*$39;FcNk+)|3lyKuu2h2YzhLWe`BT~MGsb@)r5|@%&b098csqFad(`xlk!0mF zGh>sx+48PI&U;5xa_J_1?Qt15w-0xNcZ-LS2%OHo;OedOT;N^(N%`QUyKCAH?GOg} zr_5iiyiSV}&HS-@1K%`3)ae#kMrYjP`IPS!HLLAHTLbdG?6Qvh`M$63sdvYMq~}uF zBT9tjs=Mt5YzzG9r{^-cYIfV51H+ z0CMlj{l^GBpY*yZ7(Bn7Q0|kbg?maN6ryRY*KkF8w-frVl=}j=gqRdSPGW?>#E5wU zvWP528 zPb>6ay?JqPS#YS1xw{Te-6|mxVC%f#c1Qzg1o4AU(`Y9n#=&{vZWTRqWBZB+ z@)ayC#;`Zk*QY}MBv!l_NltMMLE!}}NG9yBCpTN4(|CHTZBKJ1{w4!r-(dj}Ooh7&J{2&d{6(n>kVYQ^q zMv^F^juCn^v>gTH^W^;(@`45E74Q1>hIe<)@tCps!yP!hxf^l>rP> zx=ANX|E}12CdWMYLzjUJhBF`LfJ7=(@&%7Ji24j~+()XFpT4!-kbwAveG*VEtR zBT5r4I)DKaD$2U9_87-c`#(aN(^lux%DSC(;!jzCfJameS+}xsE}i}+AOW_d(2np# z!uo3vKq2J5Y5<*6H5?$`br&$J_M{64K)vJuQ2e)w5@8b_7BKyjl-n_Y1OVVex%>fK zk7vt&^7oA_Bo-0RxO4g2bm+Leb{K3YCyR%N#p;=V?)%XV?lM;8^e)v`O( zM#jYs0ssues{;VPI6MHNBO?kPG8~WCE892LB17WJhFkFU)DM7O#a8WUV~;QTpYg?w zJ$b?5u_R&}t;L&K?BZ|<0Rj5^q?~?G*G+f65D&zniZoq*2elGhAARact+rr5fDgI% z9r(SX{S-bU3(zK8YWE&(6P=%n83C%so)y}!$s!}75!nIjmt-A+2ml|Yu$>rGMw))M zDj#PLcmfiZ;OT6;+eW*IpMm6Sy{WIqtN=f4hh1Ms^WqkZtFG}hu~0CKCmId@Gtu+A z`iHCSFfP|C5!NME6LLrZf$H*=E(Ompzt-!~jRPqYTO{AgC*LX*FhF9W?zu&+n{>`` z4id1#v+3nh5N;TUiiRO-sJLsyU8U;5|Fs&)tm4&~h-di>87-em5%?F@Pnx>?rsxpaNG-klPUF8agJMLATB`T^#1^9SHd&Wa7erurVY}|bhJt*JUZr4jeeBT$yt3bd2mi2Lj2xt^- zb5E#0`M>$s6v#|N|Y=_i;36t^mHXXA8#`z5Rd@)=f^sq$s5eG z)sgp~M(rfBS8}*xp@5wowQT*D+Ehr( z^F1%^xr<3Sm)rdrnJzg21A^!brWmtHSr!&)$JYw`*@z%=2{upwgaz)R)?(c;5msE? zTxg30ZCRTZt=2_7N|6!X?$|0+sLl3avdi^TS0k-^MzJcu-sNI34*8gL;pDaeE)Acf z_8mm)^N1=Q4yadt%;wND%CGt^QzL&r`K=Gjdka5@!JLbi(KK@fP>8X-zTWJJeM!9` zZkHgxcgmfMUsPN)zcWhM-hJ#f<@y$j-$n8{2>=E7<4nY2O7VI>H$J?v*KFo0lgme> zWczpdb{wLoT|54ZU)|H7CDwU+Kt8M<&xp}vNbBaxELpoX7g0N$3DQ)@nbd;tLl9z-%&IG&zmDWufMkzaRi ziIaf@@U4uCBj8I7h`~0{c+1LDUr$Z2czqZra_~p2xm(yv1aQ?p18g;O=kuc*H`R|@!Nc#Bi~USQfi2R8f{jtBrNQ5S06oR6-L zDk`-JZgyLO?y_6+FsKhd~pxL_6)PUZ1^zGL3`q7@w`X0*leeVw3agPs;Z z#LRO0`(^y}@HYTOs^3$WFQ(%uZm*R}Uqw|-5?kFYgD+uf|BMj@zvR>zB{~h?p+nR( zy9yhc2X8)=6?Ok3ME_&wxEMh`JrH1kWYQc3qImPvSBg0LOBrAbwKMLdV{$xuvxhdz}eElkxgtrAk zc!qihC0sYRI5wEKlF{>xoyFr5Zz2F-xD8{a32m7YQxefhEQPc#sz|9(T8&_$;?^Td z9`gA3a_ziyJ)0!A+>06uC4pL6TAG`d4QaHtj$6?+FSg_Dn5zt-WU*Of7_g%8 zOlz@Z?1_zF$M0!57p1e+tjdVP#ep`E! z+F7OofLTUkevbZjyZpITceTXPJ#TKe8%$fhBSUOGmX9T3N%f8GnBV7;9z!;c1LwP z@BF-VAw(+XXad;zS7|SyUq?1G#sLD3K#($FdaAKiiUvGwu|LT+?ZBLnijRp)uLc_> zC4bbo(Ye%y2WNRjDu2r$GhA<_OpQl6B0X3xEgI1+siBF30`N$X^l(X)xC)PnM%vV- zzD4l#bi`84q$#OSFwM^m-ugWFQXr~mBaGhE@@#=B8Z*6L2lUdS1+a*1-(F|H>@cTo&TvC?(E5ZI-=EXx?`{2E>s?o7L}%N7T7K)_1{wm z>b0x-_{o#=yx~Zhth%RNxo8%hnhu-Xy#gGzqkInxq@6GDby9j9o``j~j~|dV>lc@o z{&+!bRMB(l*;JTvHN5HocFw4M1{*C7_l;;~x);{Qy$8xj>nrt&srj^INX6xp4$G(* zF?L0o5db{N#}6uUO^0=cJ%XRKxD))P`GtW_YGw=MTu%w!_e`XPWZ{H?dYQKWr-?6* zhr0V7zhi{TE;L9e=^-g2OV*;YD-x5n>{+r8W1Ajjmk>hPvac~k*5Qd{9T`j38HF*H z>>~`m?>>M0UcbNI+r8(Ud(J)Yd+s^+9NKjM!AJLBeAUmLX^!tGhjW4VhQ3@@L`0kE z27Jw;*!TA9Z}HZ$f*Qy)S?iU_K9|;bJ z!7TSiHUq=zi8$3?X>XG~Ki7S=$W=PW;dUCdH_)-6c}qQZyP}EV(o;G`Dam^FQ_5~y z=Z!jUkL4A2g;Tf|-X(i6xWKSdjlo{V2B&VZ#^?K=*+Tm?w?TQy72J~(Mxk`_RauMn z0Tx}9?452pc#57+ej#RePMY+4^3?BVDZyhE6jTs$-kzR52gBOztsm#rg$DJ`fcBavn#wOln3WSEVOUZO9!mHY4lDvet zK!BKQmyce%;&;h38S6tyE{g9MHS$3z*M3)L0w1LXQB5>@{cU_ll((FKY}Vpiz5iCe z=La`RS%IIs=S~My$&}#DBaz@*a^or!>~Xy_>Wc z0g{zf@cd#Q!c;8QsVES~u4{h)-x<(t37(NMG9C9PG^yh*fL>irJ@eZ$I`Y-o^!vQ& z(Lvx`XIy^%3nbgv8?g;$HASsJ*NyfW0mtc`oT$5dBQ-^rNF3k2X9jg4&CO+f8sSX- z0wZ^!h!f8qC}}+JZLPJ6{#av3f+C{!x8XY*{%bR) zFE)4_9=lJM54n5@1mwNVu9J>?q0#02ZLeQF$>v7MD%@0Z`Agw>ps$mqi{$U$$mB*@ zI*D9!e>o>!vpn9tiC3kJ`E;*zos%#yN?@`%33N=H=kvzwkEwB80M%0-n>>!ca$*{m0Cx+Ht|m{M_=eiytxvR^oDZ9K9uYMZ^rxJ zwKt+;;y?C1;#cvfq`iYn5yow*Zb+?iTsjWkS|-=Dj|0!ACznY(!Z~!mK;NaW(k4|M zL5P6*&FB&%?WxM;_Q4U`O>pJWp4L^z)O6kMNaN+{hKa5JI8^s(+-)y6DIt9!1r;3G z`LWs7uS4|6{4kM6d8eg%90Q}v#Y@BtM*B;v?#CW*KPJ$ljlZOdw<(w#e`>oNIH`)+ z$#Dv!zdhZ#p_N-CvGByJkk0DfGo|Y@I3JZK?BAB`XUgkT9GC3%GHt+#Klm3oc_orw zBh4LEL%cfUYr6^?uTeW{k^X@B;Rk8Gx@5Pc79mpN&gxJ`rVOm*OA@f{)Z(lz3LP{T zP{Sy${AkiNffUB!n<}dCSK6U|2f{)P?x_ntba9d&1=PnUzZTB^^ZCyIBtkM9-!reK zeqw1=HRfP%acfISETYVwD({A!2QGxp3j>QRgZ6>ZLtX%-G~ zSiG~6;YZi#PH!xav%&NTtF5~jCqt_OgV<^^MIgYu5RT^nO$k;#!kay5YDIeu*B1wJ z&-twm_wjWaxO9YNn@H&1Qo3|Fyu7&!R33E+4wpTZNwBzGnbnA%5Wa5Xci55(KWof| z^u8$GYsjhR@87x8pW>xBmCBJn`#8s6Cz|Pl9N1bW;yTp7M5;C&bk-HuEN^LsWp{Qk z?}TXInO48l?Z3&c**Ft)iDLIY-u2C{gEaFy1R!L`D$(DI_z)V?p83d9ZL51Z6 zot!#?4!=4ddQ20-YU!*mZnKZj&lrU66!CXt1%^_qEcfmt_LB|UhJFla`)zMWSa$Z2 zOe4jF$VIRmJE*fsheJb4Hjh&;3xEv#Q%7E^ni9XpVg3fQrB#i3Z1J>mlLZeqx7r5d zgLjyQ&|gf=Hh;1fj_tbxXHu>eF4JO*Z>hY++jNwYJGV0EKU_;aE1k`CM$ka!fGCrn z@)#MCX|exuC6lgt?u4(rT#m5npURON6}LoDD18plCyw`A`j7IP$~D;`T=WY#^A0^c z3goV319g=Oq>i&fSG7iWI9^@}rE}PdH^=8xz+0WpD%kj?CXhx-Ln3LXuFBoqq`;P2 zc`sIxzKH z9E;Os%;Nn9`Jcb9H`s(uCmai)Vl6abme3^X8vdc;$4f9SAg8Ff8_Y(1++^!NJ_Zhe_$zBbte?bAq?xI#juMazSo9sj;6Iy3&NRm16v8G|Au-q~O?|UOQELDOQkd}?2ENy5~BCAIKE#kjh z)IUuTUE@$aT3g~(mQB5#aJGVqqi-y!a>tuJ;&T;>@mh1Ga`GAVhGAN2o$iJh$T48n zEr%A4Cs6m;(@REDE7hCKK4!Crc}Cg)T%V8NUGfVf6j#5OOJV14&YdgM+A&KY_qTl{ zPsCmeNsTmr=x?j$JWV`V9OB7brEX?or70+@u+Vb&?dp5yKX&KjXxe7f%RM8xc0n0O zTVhruuhmfd>R_1nfyGjLw7l$^`c~onLaQ;VEG#(U{hS5GEgd!8&63hwC?_`PMQw7o)F_F(B8V zR_RoCO&Tn~)hSf;ok!m68wCzEcT_{+Ieg&0_w;yQ{^;itBWKCy|0xeWpp%pv6tdO= z6#R5b3kiI4t8%l5OQ_Vs14Q|l7AF3ktYL51pX>kpqkZIltT*hKVNzrA@ovHpaJG_x0B#Ol6ZX?K$ARP%&JdR4{1)Y1YG&(ZfLGoOmHfFhT> zNQ$3Twvm`&n5d=+?N@!N&qr0XGIe{l+$CdCW({e+K8j`wWQz7lz82!&!(^9rW4y)} z-=&el2Ldhy4CmEjp$)^<*o*m{^B>Pco~#1t9!@-F30l!4C4cB(ycT`%&kmXoh3W!O zRSt`n&Bvc=sOxIKtEA#4S5gmIH@4NopDPhdj&e95d!+oL zN&J^BxtQU{AaO3^StyhbSP@nvS|3Jz$U9OF@0rJ=#Dr8s;yWb$7Odr=3V4? z&A|h~B$ryxl3INnym4CL{8T#b;JW{lmKq-uf=JMdLnlsUNyAN~x%=H((#?26t6gbJ zynBlv(2oihBQ95^Hz51|ZDe%qmz(HJckI+6~xIWSx+u@s0+w!BQDWt zHx068l}=r3sSQZaxD}my5ZXOk=Bd9OqmbHLai=} zyT62#fmb_}Dcd^Vpv*_7syeyT*tcnSEVR^w0^+_d^;*y`!cA|hgbPnO&p&oYq*?nc3Skq& zR+#$b_Is4Ye(5JzA7JkNRB=ytJjP_7Q4jgH;_6aQX!7@4<+v+NXje%%8NP2z)XZ0f zS0-o68QFbxpz$Sc7w;pQa#j$|qMMWAcq+`zx-nX9cY()>)P`_`QowIj*gIwv7#6*1 zgE#%1<4xZqH>b9p_Qjp|+@>4)B2?gpih~f_2)0hvqSR}u;x~xMf$n-u5Z<6BMs&-B z#2zBujIW?KuXnM+F=sjBdY+t%#Ned13EI8z}#$I8D!W1z+QVfp}ptU+3 z`!Eu`rKL9-A5rDO?To83U2l^A6-qu)eh)hKEc)UyEKW|zrKS*&{TI)RfLA_dFn|?$ z%Z!DbHYEda@QO7Mig*-W_lE)>`HI(o2=8g^6)3o&^!HnDz3DR;R!I4Hl(z`*&SeH7 z>Ak^<_6!V)r!fH3DAh5GY%c;Dc}PbCh)^!W#dtWeLMq2ufUzh5+VelOM0yz*u5>a% zBPr^K<%pdIN`Vj_*}ss^KoDLQT=!=O^{wE!5&+C5UAd|ty;BSh9>Bot7a@QRFEKv` zJTIC4G(Qb-qecnx#{lpDfu2+Wm*5Pq?2s@R_AU=+YuBkx_P+yDW^8qYNc{~JNsPK* z*A#Z_6eH{vt147%(MQ>rMZ|~3(|UQ5yA!;DM#l!h?4P^M0b1McNA|;PPXO#&3m#6W zV^JkkdP*rk`3l4@I2Z)rTRjZ4&fM=p&Oijc`t}`qqTZKa219VrX>vvgfVch9fnt^$ zk{}fZwS#I=3F-Oy0u5#$WC&e>r{ZuI43q(KKxsG}V2Sk(sf?*-fIT`4*qfN%THO8) z(WC9>&GElB9=$N2m?~M%+s+9z#OjiRK+XHJShpGy3<7NL?JbO5USJlL95riz!(E2nY%|SPN^Y)rAKE zws^ZV4>1J_1VXlE!)SRAht)pAZLy$*=PPplMbuAV(# zBDleALd-YRl8m`&73BL~-hJX*6C2PQ9v%L^?W(X~xZ>`;<)NV_NZ#lmIFOjI=R~j@M}t@$~b#(*uaXn z>J6G~Vsir$O7fj3Q1)w1w4uh82(jz<5n zOsNy^+8ksq_P``Y4MrE^zN1foi2BmvkN=MDu1arT>wFJ9jf{X`epVJVgkJ_`k_LAI>@dbMCqSBO;GH z9o020Y}>X1e+15@37k`@RIn=*tda#nP}dlwsjUUC-%yi3PYBDhV$w;jD1}Q(VcRz4 za+!Q`g<{d9zOez9r-rU@51oCz#79ohd3QG(TW=Q6jXVQ3Va=&rF-=knf3uugqL3@l zy*F<~jZADVjIj$bol1XNeL$fBjo9i>4BhhX<7q(0a>yI<|Dt zxwV_G-}#J9+cxuibezT6Id;D~#OUE+Zrywv3yB3rkNn8u^gKcb5!p4UUij!`)_H?G z_2?isZEWZEdwV!JJjO%M?&jp^FU(9%aXxX5KgQz}E?mS2__!`qhpy{Lx8Y&t)ETBK zHm`sBe*t4Bj#Dx%8rC+lcgN!xf!b;pO>JxO2Wq*YeLcDC1xkfNl|WNUF?(i)rSmB! z&z>fiS;6BsNS{md%E9;O9oWjpv4iw(53}#9x9Gfc6GQv=(H9x0&P!WPWe`&G*v_4N z`1~6*ZD?iW&@tM(JNV(tBh_y_9*Z&eXM*J1e(ieHNkjhf%~6~ zkXz2taYq+!!%O}34TPE-NX;h+Kd_DY>3Qzk6Jf^8f-anVq@$nOU>#Evlk6XQjplVN zplg(iCN7WrlC7zsT(a=^JtX4OJn`mUf}s$$vXPGRlx^b)7}T!`5o%dOxl}?ptzFTk ze-osTZWsB?YW%AL6bpr_OFBMVC^U3*myRwqY^8H8Xwr$Paa4VUSymkgvZ)NS@xKrX z%4Yeh(wCudd0iypXUQbf7`6U?2?WZrDk414hxNY6s?|Z9o%jzFXu3u=ndYt?{rq(F zduPFvG<-hs+qa`wMHA_Evy#qUv;E6(Ii4_l=sPB&V|aarkU|%+TqYYn_Qm0=NA~}? h6s5dAuRznI{{YYUMKzX<1~vcy002ovPDHLkV1oVKrM& + id="rect1" + style="fill:#49505a;fill-opacity:1" /> + style="stroke-width:0.909753;fill:#d7f1ea;fill-opacity:1" /> From fd3bf7a2f7e1dbb204be83a178707fbbd12a3a18 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Feb 2026 21:56:58 -0500 Subject: [PATCH 033/163] [dist] Update logo colors --- dist/pyscenedetect.ico | Bin 21797 -> 23367 bytes dist/pyscenedetect.svg | 16 ++++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index 608b234438e8e606080f16cef1d9abb77e6fd7a1..07b3f17cbb183eccb7c320fe8d4858a1af681fc4 100644 GIT binary patch literal 23367 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 + style="fill:#e0e8f0;fill-opacity:1" /> + style="fill:#2a3545;fill-opacity:1;stroke-width:0.986138" /> From b661a1cc799b6928d2d5e6a82998214730f57ede Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Feb 2026 22:04:20 -0500 Subject: [PATCH 034/163] [dist] Add new logo SVG with text --- dist/pyscenedetect-logo.svg | 65 +++++++++++++++++++++++++++++++++++++ dist/pyscenedetect.svg | 6 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 dist/pyscenedetect-logo.svg diff --git a/dist/pyscenedetect-logo.svg b/dist/pyscenedetect-logo.svg new file mode 100644 index 00000000..7f09ad70 --- /dev/null +++ b/dist/pyscenedetect-logo.svg @@ -0,0 +1,65 @@ + + + + + + + + + + PySceneDetect + diff --git a/dist/pyscenedetect.svg b/dist/pyscenedetect.svg index f768ce6a..46dfbcdc 100644 --- a/dist/pyscenedetect.svg +++ b/dist/pyscenedetect.svg @@ -23,8 +23,8 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="1.1396485" - inkscape:cx="-11.407026" - inkscape:cy="169.78919" + inkscape:cx="338.26219" + inkscape:cy="35.537273" inkscape:window-width="3440" inkscape:window-height="1369" inkscape:window-x="-8" @@ -41,7 +41,7 @@ ry="2.5" fill="#e0e8f0" id="rect1" - style="fill:#e0e8f0;fill-opacity:1" /> + style="display:inline;fill:#e0e8f0;fill-opacity:1" /> Date: Fri, 20 Feb 2026 22:24:44 -0500 Subject: [PATCH 035/163] [dist] Improve logo ledgibility at small sizes --- dist/generate_ico.py | 85 ++++++++++++++++++++++++------ dist/logo/pyscenedetect-24-48.svg | 72 +++++++++++++++++++++++++ dist/logo/pyscenedetect-32.svg | 72 +++++++++++++++++++++++++ dist/logo/pyscenedetect-64+.svg | 70 ++++++++++++++++++++++++ dist/pyscenedetect.ico | Bin 23367 -> 20638 bytes dist/pyscenedetect.svg | 53 ------------------- 6 files changed, 282 insertions(+), 70 deletions(-) create mode 100644 dist/logo/pyscenedetect-24-48.svg create mode 100644 dist/logo/pyscenedetect-32.svg create mode 100644 dist/logo/pyscenedetect-64+.svg delete mode 100644 dist/pyscenedetect.svg diff --git a/dist/generate_ico.py b/dist/generate_ico.py index c18d57b7..c9280e22 100644 --- a/dist/generate_ico.py +++ b/dist/generate_ico.py @@ -13,22 +13,67 @@ from PIL import Image, ImageFilter -# Different raster sizes to include in the ICO file. -SIZES = [16, 24, 32, 48, 64, 128, 256] +# Colors matching the SVG design +BG = (224, 232, 240, 255) # #e0e8f0 +FG = (42, 53, 69, 255) # #2a3545 + +RASTER_SIZES = [16, 24, 32, 48, 64, 128, 256] -# Sharpen smaller sizes to improve ledgibility. SHARPEN_AMOUNT = { - 16: 200, - 24: 200, - 32: 100, - 48: 50, - 64: 50, + 24: 75, + 32: 75, + 48: 75, + 64: 100, + 128: 150, + 256: 150, } +SHARPEN_RADIUS = 0.5 + DIST_DIR = Path(__file__).resolve().parent -SVG_PATH = DIST_DIR / "pyscenedetect.svg" +LOGO_DIR = DIST_DIR / "logo" ICO_PATH = DIST_DIR / "pyscenedetect.ico" +SVG_FOR_SIZE: dict[int, Path] = { + 24: LOGO_DIR / "pyscenedetect-24-48.svg", + 32: LOGO_DIR / "pyscenedetect-32.svg", + 48: LOGO_DIR / "pyscenedetect-24-48.svg", + 64: LOGO_DIR / "pyscenedetect-64+.svg", + 128: LOGO_DIR / "pyscenedetect-64+.svg", + 256: LOGO_DIR / "pyscenedetect-64+.svg", +} + + +def make_icon_16() -> Image.Image: + """Create a hand-crafted 16x16 clapperboard icon.""" + img = Image.new("RGBA", (16, 16), FG) + px = img.load() + + # Clear 1px padding on all sides + for i in range(16): + px[0, i] = BG + px[15, i] = BG + px[i, 0] = BG + px[i, 15] = BG + + # Arm stripe gaps (rows 2–4): clear pixels not part of a complete stripe. + # A stripe x+y=s spans all 3 arm rows only when 5 <= s <= 16. + for y in range(2, 5): + for x in range(1, 15): + if y < 4 and x < 3: + continue + if y > 2 and x > 12: + continue + if not ((x + y) % 4 < 2 and 5 <= (x + y) <= 16): + px[x, y] = BG + + # Slate interior (rows 8–12, cols 3–12) + for y in range(8, 13): + for x in range(3, 13): + px[x, y] = BG + + return img + def find_inkscape() -> str: """Find the Inkscape executable.""" @@ -55,15 +100,21 @@ def render_svg(inkscape: str, svg: Path, output: Path, size: int): def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: """Render the SVG at all icon sizes, applying sharpening where configured.""" images = [] - for size in SIZES: + for size in RASTER_SIZES: png_path = work_dir / f"icon_{size}.png" - print(f" Rendering {size}x{size}...") - render_svg(inkscape, SVG_PATH, png_path, size) - img = Image.open(png_path).copy() - if size in SHARPEN_AMOUNT: - img = img.filter(ImageFilter.UnsharpMask(radius=0.5, percent=SHARPEN_AMOUNT[size], threshold=0)) - print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + if size == 16: + print(f" Using hand-crafted {size}x{size} icon...") + img = make_icon_16() img.save(png_path) + else: + svg_path = SVG_FOR_SIZE[size] + print(f" Rendering {size}x{size} using {svg_path.name}...") + render_svg(inkscape, svg_path, png_path, size) + img = Image.open(png_path).copy() + if size in SHARPEN_AMOUNT: + img = img.filter(ImageFilter.UnsharpMask(radius=SHARPEN_RADIUS, percent=SHARPEN_AMOUNT[size], threshold=0)) + print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + img.save(png_path) images.append(img) return images @@ -76,7 +127,7 @@ def main(): inkscape = find_inkscape() print(f"Using Inkscape: {inkscape}") - print(f"Input SVG: {SVG_PATH}") + print(f"Logo directory: {LOGO_DIR}") ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() with ctx as work: diff --git a/dist/logo/pyscenedetect-24-48.svg b/dist/logo/pyscenedetect-24-48.svg new file mode 100644 index 00000000..b1b53949 --- /dev/null +++ b/dist/logo/pyscenedetect-24-48.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + diff --git a/dist/logo/pyscenedetect-32.svg b/dist/logo/pyscenedetect-32.svg new file mode 100644 index 00000000..63225e8f --- /dev/null +++ b/dist/logo/pyscenedetect-32.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + diff --git a/dist/logo/pyscenedetect-64+.svg b/dist/logo/pyscenedetect-64+.svg new file mode 100644 index 00000000..9c2c987a --- /dev/null +++ b/dist/logo/pyscenedetect-64+.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index 07b3f17cbb183eccb7c320fe8d4858a1af681fc4..3886a457aade06e7724196d4336473eff1708ac1 100644 GIT binary patch literal 20638 zcmagFbyOWe(>Hi8?ykX|1P|`+9^BpCFYfM;;0^(TyA#}lyUWGhU6%KG_S>_6?AdqD z8L6o{(>2}GRlln00RVsiSO6Lt;BzAd{QNw+f9`mA|D{o(000EwCx`UE^fWX8fd9EO zGyj*CfCT`K5dZ)oq5slSpV!P706;*%f9X+l002bzKiB`Wkpgtd0RRz303cFHK@u6^ z%cnE|Sz1c$$LINfG(dxc`8<-`d>DP6bfm?ERY6%MK9@Uv{xApt#E8plWGdO4rtxz8 zv>Nyz=2{~3wZz0Y0HEE5deT(_^(dC z)#V)s)Ufz(&T5_7gXHIBVr9TwPJ>{s2D84X51{n`3=TAkwg9eLlS$D2xah@-(_cr-zQl^@Y|e`{k4gRuep#l4u?rXY zWB`^cyjDtn**8_Sz%pw9mvtkL&n9jk65(Re0f8CyU;eN8eJdpe|G}}{T#t~A-3K4-}L@r@r z;eg0>G5xN^r}qwth$v3a_pPbiQo}HV%rYa)qI}ZvLYR1#75d-!M83KQt`(>2?~$)MxjtQ*s}ZjWd;Vt!xV?=>?E{Yx(G(hiA2#Vje&-uT3YQ_VoGZj9ZbCQ&G%*gX+oF)At`uGx5nfUcQZM`VoyyEF zSydHz;^^f~>0|ulU?N+b$BZJKuwQ9K#9$IXV!W3Njk()KJ1YZK&j!QA$fnjb!H1WZ zy=+Gcdgu2^Tj`=6Z4{DLN@;;aUvRO_6dRF#A#n0$7%8Kw&tbq=cacrCj2HBG#`Zdp zh<>zu-uiKC#aq)Ld*C2fFMQQ0| z{3N72&NFI>3SPv}2)>B_bPYeGYn*tWtgI|8j%>ExuBQMM8o#3+?0Pq|$Rmpf;>}H0 z*Gh5wixAfz0=UekxI;*;Y5q%pQja#+w$M4F3G_&$J|Q;iZsvY$kn>@ioC|89-K^iq zcLnjn29x!$Rjft=DGa1_(i3})Z=h?gE{oX!=+cHNE@?t)$vApK)QJ(ixD&M0ElL5W zqp>=ANphoW_H6YJDq$N>Bki#f@b!N& z2c(~T(*KwP7cZ|{0027ezvf^-!_HPk4bN}ltn;kRoygCY6<9|eao%@l5*%wq2^Pit zc-9cbBB#Vaj$$Uo!%I5!gEOV%5yE^wM8(*PCrhGbfxt}sWgqNjFNMzBhsUwt{?POQ zPJ3{rTVJs6#@gK9UHkixb;EaYV5N1tAjREs#~%>-h#LY-u;o~?Wrln9+}XCZ11QW&jf$W zQXPxee&ZGMfLAm^_gs{AE_CBBTpA^!5IM{sRH+pnL|Bg1#o21J)8nObg-$z6S=q)w zgt;f_natPv5oS48HvXz>84UtrwiFdR+l6IMdi_EF#!4QE9jrDBPniOeaQ2>&yuM0C+7Swc45vLjF$lL`)15W_iT0{OI)SzKe2U=0j0tC5jwrNugb5h%uNf z`fFETA$%9JB7Z=VX>hT%BM;H#OWE|4Qn*ko>ind7)MJt?yprPYnc9QpGZ<8f?mGIP zKl#E|gaCm;$U%()s(jqIrtv+%J^+1k)`U3z>Q_R{5Q}xRql=V*M5!2sGsppM5oU$n zB4K#5Vz6Ym+^-Qs6}MXkoM^I@&0l{YJgHHYzH6@q=_zly@`wxOsmoB7<`AKHr^Gq; z3P^MQognjqo=&j+0V6sJ%UHqAki~b!UUl!j)t5pv)j&y~4Bm}&aAcneTv*^&Vz;v; zI`W&yKk44!=M|g5fP6NM&WZ=r3OXN>j*iMW9lO#rv0Y$1i{=suX<2=pc%2EBzu-WW zjc;hquV|pGT%ALjz>90}Pg-8>@NI5Ec9C&)Oq<2~!64w_#9yshm%((+a9}_zIN~

h3Y0Mu<51_*#2J( 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 QjyrNQxrD>(|M!;s8(P!R%K!iX literal 23367 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 - - - - - - - - - From 0e0dfea4cb023b09c6d94d98ab1868c5edfafbb3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 22 Feb 2026 19:57:38 -0500 Subject: [PATCH 036/163] [dist] Finalize new large project logo --- dist/generate_ico.py | 10 +- ...edetect-24-48.svg => pyscenedetect-24.svg} | 19 +++- dist/logo/pyscenedetect-32.svg | 21 ++-- dist/logo/pyscenedetect-64+.svg | 70 ------------- dist/logo/pyscenedetect.svg | 99 ++++++++++++++++++ dist/pyscenedetect.ico | Bin 20638 -> 27826 bytes 6 files changed, 133 insertions(+), 86 deletions(-) rename dist/logo/{pyscenedetect-24-48.svg => pyscenedetect-24.svg} (82%) delete mode 100644 dist/logo/pyscenedetect-64+.svg create mode 100644 dist/logo/pyscenedetect.svg diff --git a/dist/generate_ico.py b/dist/generate_ico.py index c9280e22..9dd535c1 100644 --- a/dist/generate_ico.py +++ b/dist/generate_ico.py @@ -35,12 +35,12 @@ ICO_PATH = DIST_DIR / "pyscenedetect.ico" SVG_FOR_SIZE: dict[int, Path] = { - 24: LOGO_DIR / "pyscenedetect-24-48.svg", + 24: LOGO_DIR / "pyscenedetect-24.svg", 32: LOGO_DIR / "pyscenedetect-32.svg", - 48: LOGO_DIR / "pyscenedetect-24-48.svg", - 64: LOGO_DIR / "pyscenedetect-64+.svg", - 128: LOGO_DIR / "pyscenedetect-64+.svg", - 256: LOGO_DIR / "pyscenedetect-64+.svg", + 48: LOGO_DIR / "pyscenedetect.svg", + 64: LOGO_DIR / "pyscenedetect.svg", + 128: LOGO_DIR / "pyscenedetect.svg", + 256: LOGO_DIR / "pyscenedetect.svg", } diff --git a/dist/logo/pyscenedetect-24-48.svg b/dist/logo/pyscenedetect-24.svg similarity index 82% rename from dist/logo/pyscenedetect-24-48.svg rename to dist/logo/pyscenedetect-24.svg index b1b53949..9eb62d50 100644 --- a/dist/logo/pyscenedetect-24-48.svg +++ b/dist/logo/pyscenedetect-24.svg @@ -1,11 +1,20 @@ + diff --git a/dist/logo/pyscenedetect-32.svg b/dist/logo/pyscenedetect-32.svg index 63225e8f..a6e64900 100644 --- a/dist/logo/pyscenedetect-32.svg +++ b/dist/logo/pyscenedetect-32.svg @@ -1,11 +1,20 @@ + diff --git a/dist/logo/pyscenedetect-64+.svg b/dist/logo/pyscenedetect-64+.svg deleted file mode 100644 index 9c2c987a..00000000 --- a/dist/logo/pyscenedetect-64+.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - diff --git a/dist/logo/pyscenedetect.svg b/dist/logo/pyscenedetect.svg new file mode 100644 index 00000000..ad5c55e2 --- /dev/null +++ b/dist/logo/pyscenedetect.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index 3886a457aade06e7724196d4336473eff1708ac1..019c86150820b2975616d560c6cd01bc7cdd0aeb 100644 GIT binary patch literal 27826 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni literal 20638 zcmagFbyOWe(>Hi8?ykX|1P|`+9^BpCFYfM;;0^(TyA#}lyUWGhU6%KG_S>_6?AdqD z8L6o{(>2}GRlln00RVsiSO6Lt;BzAd{QNw+f9`mA|D{o(000EwCx`UE^fWX8fd9EO zGyj*CfCT`K5dZ)oq5slSpV!P706;*%f9X+l002bzKiB`Wkpgtd0RRz303cFHK@u6^ z%cnE|Sz1c$$LINfG(dxc`8<-`d>DP6bfm?ERY6%MK9@Uv{xApt#E8plWGdO4rtxz8 zv>Nyz=2{~3wZz0Y0HEE5deT(_^(dC z)#V)s)Ufz(&T5_7gXHIBVr9TwPJ>{s2D84X51{n`3=TAkwg9eLlS$D2xah@-(_cr-zQl^@Y|e`{k4gRuep#l4u?rXY zWB`^cyjDtn**8_Sz%pw9mvtkL&n9jk65(Re0f8CyU;eN8eJdpe|G}}{T#t~A-3K4-}L@r@r z;eg0>G5xN^r}qwth$v3a_pPbiQo}HV%rYa)qI}ZvLYR1#75d-!M83KQt`(>2?~$)MxjtQ*s}ZjWd;Vt!xV?=>?E{Yx(G(hiA2#Vje&-uT3YQ_VoGZj9ZbCQ&G%*gX+oF)At`uGx5nfUcQZM`VoyyEF zSydHz;^^f~>0|ulU?N+b$BZJKuwQ9K#9$IXV!W3Njk()KJ1YZK&j!QA$fnjb!H1WZ zy=+Gcdgu2^Tj`=6Z4{DLN@;;aUvRO_6dRF#A#n0$7%8Kw&tbq=cacrCj2HBG#`Zdp zh<>zu-uiKC#aq)Ld*C2fFMQQ0| z{3N72&NFI>3SPv}2)>B_bPYeGYn*tWtgI|8j%>ExuBQMM8o#3+?0Pq|$Rmpf;>}H0 z*Gh5wixAfz0=UekxI;*;Y5q%pQja#+w$M4F3G_&$J|Q;iZsvY$kn>@ioC|89-K^iq zcLnjn29x!$Rjft=DGa1_(i3})Z=h?gE{oX!=+cHNE@?t)$vApK)QJ(ixD&M0ElL5W zqp>=ANphoW_H6YJDq$N>Bki#f@b!N& z2c(~T(*KwP7cZ|{0027ezvf^-!_HPk4bN}ltn;kRoygCY6<9|eao%@l5*%wq2^Pit zc-9cbBB#Vaj$$Uo!%I5!gEOV%5yE^wM8(*PCrhGbfxt}sWgqNjFNMzBhsUwt{?POQ zPJ3{rTVJs6#@gK9UHkixb;EaYV5N1tAjREs#~%>-h#LY-u;o~?Wrln9+}XCZ11QW&jf$W zQXPxee&ZGMfLAm^_gs{AE_CBBTpA^!5IM{sRH+pnL|Bg1#o21J)8nObg-$z6S=q)w zgt;f_natPv5oS48HvXz>84UtrwiFdR+l6IMdi_EF#!4QE9jrDBPniOeaQ2>&yuM0C+7Swc45vLjF$lL`)15W_iT0{OI)SzKe2U=0j0tC5jwrNugb5h%uNf z`fFETA$%9JB7Z=VX>hT%BM;H#OWE|4Qn*ko>ind7)MJt?yprPYnc9QpGZ<8f?mGIP zKl#E|gaCm;$U%()s(jqIrtv+%J^+1k)`U3z>Q_R{5Q}xRql=V*M5!2sGsppM5oU$n zB4K#5Vz6Ym+^-Qs6}MXkoM^I@&0l{YJgHHYzH6@q=_zly@`wxOsmoB7<`AKHr^Gq; z3P^MQognjqo=&j+0V6sJ%UHqAki~b!UUl!j)t5pv)j&y~4Bm}&aAcneTv*^&Vz;v; zI`W&yKk44!=M|g5fP6NM&WZ=r3OXN>j*iMW9lO#rv0Y$1i{=suX<2=pc%2EBzu-WW zjc;hquV|pGT%ALjz>90}Pg-8>@NI5Ec9C&)Oq<2~!64w_#9yshm%((+a9}_zIN~

  Latest Release: v0.6.7 (August 24, 2025)

  Download        Changelog        Documentation        Getting Started -
-See the changelog for the latest release notes and known issues.
**PySceneDetect** is a tool for **detecting shot changes in videos** ([example](cli.md)), and can **automatically split the video into separate clips**. PySceneDetect is free and open-source software, and has several [detection methods](features.md#detection-methods) to find fast-cuts and threshold-based fades. diff --git a/website/pages/style.css b/website/pages/style.css index f5ca61cf..348f44f8 100644 --- a/website/pages/style.css +++ b/website/pages/style.css @@ -18,4 +18,9 @@ padding:4px 6px; margin-bottom:.809em; max-width:100% +} + + +#side-nav-logo { + margin-bottom: -1em; } \ No newline at end of file From 11381c504ae05bd585a1305eea87ab45008602e1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 26 Feb 2026 21:42:36 -0500 Subject: [PATCH 040/163] [docs] Add favicon for website and docs --- dist/generate_assets.py | 9 +++++++++ docs/_static/favicon.ico | Bin 0 -> 27826 bytes docs/conf.py | 1 + website/mkdocs.yml | 1 + website/pages/img/favicon.ico | Bin 0 -> 27826 bytes 5 files changed, 11 insertions(+) create mode 100644 docs/_static/favicon.ico create mode 100644 website/pages/img/favicon.ico diff --git a/dist/generate_assets.py b/dist/generate_assets.py index 699b1b8b..a6f71a5b 100644 --- a/dist/generate_assets.py +++ b/dist/generate_assets.py @@ -48,6 +48,11 @@ class LogoOutput(NamedTuple): # Heights match the natural SVG aspect ratio (1024x480). # _small outputs use the -bg variant (background included). +FAVICON_OUTPUTS: list[Path] = [ + REPO_DIR / "docs" / "_static" / "favicon.ico", + REPO_DIR / "website" / "pages" / "img" / "favicon.ico", +] + LOGO_OUTPUTS: list[LogoOutput] = [ LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo.png", 900, 422, LOGO_SVG), LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo_small.png", 300, 141, LOGO_BG_SVG), @@ -165,6 +170,10 @@ def main(): images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) print(f"Output ICO: {ICO_PATH}") + print("Copying favicons...") + for dest in FAVICON_OUTPUTS: + shutil.copy2(ICO_PATH, dest) + print(f" {dest.relative_to(REPO_DIR)}") render_logos(inkscape) diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..019c86150820b2975616d560c6cd01bc7cdd0aeb GIT binary patch literal 27826 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni literal 0 HcmV?d00001 diff --git a/docs/conf.py b/docs/conf.py index 60ad6188..72ade37d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,6 +78,7 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] html_css_files = ["pyscenedetect.css"] +html_favicon = "favicon.ico" # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 501fc51e..71ed0b0b 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -12,6 +12,7 @@ copyright: 'Copyright © 2014-2024 Brandon Castellano. All rights reserved. theme: name: readthedocs logo: img/pyscenedetect_logo_small.png + favicon: img/favicon.ico custom_dir: overrides # TODO: deprecated option for this theme google_analytics: ['UA-72551323-1', 'auto'] diff --git a/website/pages/img/favicon.ico b/website/pages/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..019c86150820b2975616d560c6cd01bc7cdd0aeb GIT binary patch literal 27826 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni literal 0 HcmV?d00001 From 4da7268e22b3daea1f994d7f339ee5fe7228cb73 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:21:56 -0600 Subject: [PATCH 041/163] ci: Add minimal workflow_dispatch for docker-publish --- .github/workflows/docker-publish.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..972f2973 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,14 @@ +name: Publish Docker Image + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Placeholder Step + run: echo "This workflow is meant to be run from the fng-infra-docker-ci branch." From 054fbd94d7b684689fbdc0ca6cf1524975daa467 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:17:12 -0600 Subject: [PATCH 042/163] feat: add Docker support and GHCR publish workflow --- .dockerignore | 64 ++++++++++++++++++++++++++++ .github/workflows/docker-publish.yml | 64 +++++++++++++++++++++++++--- .gitignore | 1 + Dockerfile | 33 ++++++++++++++ 4 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f3fcd2d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,64 @@ +# .dockerignore +# Copyright (C) 2026 FNGarvin. All rights reserved. +# License: BSD-3-Clause + +# Version control +.git +.gitignore +.github + +# Python / Build artifacts +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing / Tooling +.pytest_cache/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.mypy_cache/ +.hypothesis/ + +# IDE / Project files +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Project specific exclusions (to keep the image lean) +test_clips/ +tests/ +docs/ +website/ +benchmark/ +scenedetect.cfg + +# EOF .dockerignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 972f2973..07b859b2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,14 +1,68 @@ name: Publish Docker Image +# Copyright (C) 2026 FNGarvin. All rights reserved. +# License: BSD-3-Clause + on: workflow_dispatch: + push: + branches: [ "main", "fng-infra-docker-ci" ] + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - build: + build-and-push: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - - - name: Placeholder Step - run: echo "This workflow is meant to be run from the fng-infra-docker-ci branch." + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + +# EOF docker-publish.yml diff --git a/.gitignore b/.gitignore index c6d36daa..8c19057b 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ dmypy.json .pyre/ .pytype/ cython_debug/ +test_clips/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5b13ffe1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Containerfile for PySceneDetect +# Copyright (C) 2026 FNGarvin. All rights reserved. +# License: BSD-3-Clause + +FROM python:3.11.11-slim + +# Create a non-root user for security hardening +RUN useradd -m scenedetect + +# Set working directory and copy files with correct ownership +WORKDIR /app +COPY --chown=scenedetect:scenedetect . . + +# Install necessary system dependencies as root first +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + mkvtoolnix && \ + rm -rf /var/lib/apt/lists/* + +# Install PySceneDetect with headless OpenCV and other optional media backends +# pyav is highly recommended for faster/more robust video decodes +# moviepy provides an alternative video splitting backend +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install ".[opencv-headless,pyav,moviepy]" + +# Switch to the non-root user +USER scenedetect + +# The default behavior is to run the CLI +ENTRYPOINT ["scenedetect"] + +# EOF Dockerfile From 043b0ecd986cb9d1b101d7e143367892531b8e98 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:31:01 -0600 Subject: [PATCH 043/163] security: upgrade jinja2 to 3.1.6 to fix sandbox breakout vulnerability --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index 8455efc2..945314b3 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.1.5 +jinja2==3.1.6 \ No newline at end of file From be276dfb6d2da4a5906372a40252bb10da53c86a Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:32:59 -0600 Subject: [PATCH 044/163] security: prevent script injection in verify-build job --- .github/workflows/publish-pypi.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fcc75863..77181f96 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -22,10 +22,12 @@ jobs: steps: - name: Check workflows uses: actions/github-script@v6 + env: + TAG: ${{ github.event.inputs.tag }} with: script: | const { owner, repo } = context.repo; - const tag = "${{ github.event.inputs.tag }}"; + const tag = process.env.TAG; const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; let workflowConclusions = {}; From 4bcce298f003e6454014fd94ba81268aca396063 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 5 Apr 2026 21:04:58 -0400 Subject: [PATCH 045/163] [docs] Update docs for v0.7 release --- docs/api.rst | 1 + docs/api/migration_guide.rst | 206 +++++++++++++++++++++++++++++++++++ website/pages/changelog.md | 54 ++++++--- 3 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 docs/api/migration_guide.rst diff --git a/docs/api.rst b/docs/api.rst index 76cff84d..54378ccc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -105,6 +105,7 @@ Module Reference :caption: PySceneDetect Module Documentation :name: fullapitoc + api/migration_guide api/detectors api/scene_manager api/common diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst new file mode 100644 index 00000000..a3ae9fa7 --- /dev/null +++ b/docs/api/migration_guide.rst @@ -0,0 +1,206 @@ + +.. _scenedetect-migration-guide: + +*********************************************************************** +Migration Guide: v0.6 to v0.7 +*********************************************************************** + +PySceneDetect v0.7 is a major release that overhauls timestamp handling to support variable framerate (VFR) videos. While the high-level :func:`scenedetect.detect` workflow is largely unchanged, several internal APIs have been restructured. This guide covers the changes needed to update applications from v0.6 to v0.7. + +The minimum supported Python version is now **Python 3.10**. + + +======================================================================= +Quick Check +======================================================================= + +If your code only uses :func:`scenedetect.detect` with a built-in detector, it should work without changes: + +.. code:: python + + # This still works in v0.7 + from scenedetect import detect, ContentDetector + scenes = detect("video.mp4", ContentDetector()) + + +======================================================================= +Import Changes +======================================================================= + +Several submodules have been reorganized. If you import directly from `scenedetect` you do not need to make any changes. Update imports as follows: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - v0.6 + - v0.7 + * - ``from scenedetect.frame_timecode import FrameTimecode`` + - ``from scenedetect.common import FrameTimecode`` + * - ``from scenedetect.scene_detector import SceneDetector`` + - ``from scenedetect.detector import SceneDetector`` + * - ``from scenedetect.video_splitter import split_video_ffmpeg`` + - ``from scenedetect.output import split_video_ffmpeg`` + * - ``from scenedetect.video_splitter import split_video_mkvmerge`` + - ``from scenedetect.output import split_video_mkvmerge`` + * - ``from scenedetect.scene_manager import save_images`` + - ``from scenedetect.output import save_images`` + * - ``from scenedetect.scene_manager import write_scene_list`` + - ``from scenedetect.output import write_scene_list`` + * - ``from scenedetect.scene_manager import write_scene_list_html`` + - ``from scenedetect.output import write_scene_list_html`` + * - ``from scenedetect.video_manager import VideoManager`` + - Removed. Use :func:`scenedetect.open_video` instead. + +.. note:: + + Most commonly used types and functions are also available directly from the top-level ``scenedetect`` package (e.g. ``from scenedetect import FrameTimecode``), which has not changed. + + +======================================================================= +Custom Detector Changes +======================================================================= + +If you have written a custom :class:`SceneDetector ` subclass, there are several interface changes. + +``process_frame`` Signature +----------------------------------------------------------------------- + +The ``frame_num`` parameter (``int``) has been replaced with ``timecode`` (:class:`FrameTimecode `): + +.. code:: python + + # v0.6 + class MyDetector(SceneDetector): + def process_frame(self, frame_num: int, frame_img) -> List[int]: + ... + + # v0.7 + class MyDetector(SceneDetector): + def process_frame(self, timecode: FrameTimecode, frame_img) -> List[FrameTimecode]: + ... + +The same change applies to ``post_process()``. If you need the frame number, use ``timecode.frame_num``. + +``SceneDetector`` is Now Abstract +----------------------------------------------------------------------- + +``SceneDetector`` is now a Python `abstract class `_. Subclasses **must** implement ``process_frame()``. + +Removed Methods and Properties +----------------------------------------------------------------------- + +The following have been removed from the ``SceneDetector`` interface: + +- ``is_processing_required()`` - detectors can now assume they always have frame data +- ``stats_manager_required`` property - no longer needed +- ``SparseSceneDetector`` interface - removed entirely + + +======================================================================= +``FrameTimecode`` Changes +======================================================================= + +Read-Only Properties +----------------------------------------------------------------------- + +``frame_num`` and ``framerate`` are now read-only properties. To change them, construct a new ``FrameTimecode``: + +.. code:: python + + # v0.6 - direct assignment + tc.frame_num = 100 # No longer works + + # v0.7 - construct new instance + tc = FrameTimecode(100, tc.framerate) + +New Properties +----------------------------------------------------------------------- + +Access ``frame_num``, ``framerate``, and ``seconds`` as properties instead of getter methods: + +.. code:: python + + tc = FrameTimecode(100, 24.0) + tc.frame_num # 100 + tc.framerate # Fraction(24, 1) + tc.seconds # ~4.167 + +Removed Methods +----------------------------------------------------------------------- + +- ``previous_frame()`` - removed, use ``FrameTimecode(tc.frame_num - 1, tc.framerate)`` instead + + +======================================================================= +Framerate and Timestamp Changes +======================================================================= + +Rational Framerates +----------------------------------------------------------------------- + +``VideoStream.frame_rate`` now returns a ``Fraction`` instead of ``float``. Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values: + +.. code:: python + + from fractions import Fraction + video = open_video("video.mp4") + assert isinstance(video.frame_rate, Fraction) + # e.g. Fraction(24000, 1001) instead of 23.976023976... + +PTS-Backed Timestamps +----------------------------------------------------------------------- + +All backends now return presentation timestamp (PTS) backed values from ``VideoStream.position``. This enables correct handling of VFR videos. + +``FrameTimecode`` has new ``time_base`` and ``pts`` properties for accessing the underlying timing information. For VFR videos, ``frame_num`` is now an approximation based on PTS-derived time rather than a sequential count. + + +======================================================================= +``StatsManager`` Changes +======================================================================= + +The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now take a ``FrameTimecode`` instead of ``int`` for the frame identifier, matching the detector interface change. + + +======================================================================= +Removed APIs +======================================================================= + +The following deprecated APIs have been fully removed in v0.7: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Removed + - Replacement + * - ``scenedetect.video_manager`` module + - :func:`scenedetect.open_video` + * - ``base_timecode`` parameter (various functions) + - No longer needed, remove the argument + * - ``video_manager`` parameter (various functions) + - Use ``video`` parameter instead + * - ``SceneManager.get_event_list()`` + - Use ``SceneManager.get_cut_list()`` or ``SceneManager.get_scene_list()`` + * - ``AdaptiveDetector.get_content_val()`` + - Use ``StatsManager`` to query metrics + * - ``AdaptiveDetector(min_delta_hsv=...)`` + - Use ``min_content_val`` parameter instead + * - ``VideoStream.read(advance=...)`` + - Call ``read()`` without the ``advance`` parameter + * - ``SparseSceneDetector`` + - No direct replacement, use ``SceneDetector`` + +.. note:: + + Deprecated v0.6 compatibility shims that still exist now emit warnings using the ``warnings`` module. Address any ``DeprecationWarning`` messages to prepare for future releases. + + +======================================================================= +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. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e9da750c..acfb9ffd 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -680,30 +680,48 @@ Although there have been minimal changes to most API examples, there are several ### API Changes - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: +**VFR & Timestamp Overhaul:** + + * Add new `Timecode` type to represent frame timings in terms of the video's source timebase + * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information + * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` + * `VideoStream.frame_rate` now returns `Fraction` instead of `float` + * Framerates are now stored as rational `Fraction` values (e.g. `Fraction(24000, 1001)` instead of `23.976`) to avoid float precision loss + * Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values + * `FrameTimecode.frame_num` is now approximate for VFR video (based on PTS-derived time) + +**Detector Interface:** + + * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface [#168](https://github.com/Breakthrough/PySceneDetect/issues/168): * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` - * Move existing functionality to new submodules: - * Detector interface in `scenedetect.scene_detector` moved to `scenedetect.detector` - * Timecode types in `scenedetect.frame_timecode` moved to `scenedetect.common` - * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) - * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) - * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead - * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it - * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead - * `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them - * Remove `FrameTimecode.previous_frame()` method - * Remove `SceneDetector.is_processing_required()` method + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called + * Remove `SceneDetector.is_processing_required()` method + * Remove `SceneDetector.stats_manager_required` property, no longer required * Remove deprecated `SparseSceneDetector` interface + +**Module Reorganization:** + + * `scenedetect.scene_detector` moved to `scenedetect.detector` + * `scenedetect.frame_timecode` moved to `scenedetect.common` + * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + +**FrameTimecode:** + + * `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them + * Add properties to access `frame_num`, `framerate`, and `seconds` instead of getter methods + * Remove `FrameTimecode.previous_frame()` method + * Deprecated functionality preserved from v0.6 now uses the `warnings` module + +**Removals:** + + * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead + * Remove deprecated parameters `base_timecode` and `video_manager` from various functions * Remove deprecated `SceneManager.get_event_list()` method * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` - * Remove `SceneDetector.stats_manager_required` property, no longer required - * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) - * Deprecated functionality preserved from v0.6 now uses the `warnings` module - * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods - * Add new `Timecode` type to represent frame timings in terms of the video's source timebase - * Add new `time_base` and `pts` properties to `FrameTimecode` to provide more accurate timing information + From f0b66ef0027eb4c5f4ae3e57bacd0528080bc908 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:20:08 -0500 Subject: [PATCH 046/163] chore: update file headers to match project standard --- .dockerignore | 15 +++++++++++---- .github/workflows/docker-publish.yml | 5 +---- Dockerfile | 15 +++++++++++---- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.dockerignore b/.dockerignore index f3fcd2d2..a6f82cd7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,14 @@ -# .dockerignore -# Copyright (C) 2026 FNGarvin. All rights reserved. -# License: BSD-3-Clause +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# # Version control .git @@ -61,4 +69,3 @@ website/ benchmark/ scenedetect.cfg -# EOF .dockerignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 07b859b2..09e27df2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,8 +1,6 @@ +# Build and publish PySceneDetect Docker image to GitHub Container Registry (GHCR). name: Publish Docker Image -# Copyright (C) 2026 FNGarvin. All rights reserved. -# License: BSD-3-Clause - on: workflow_dispatch: push: @@ -65,4 +63,3 @@ jobs: subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true -# EOF docker-publish.yml diff --git a/Dockerfile b/Dockerfile index 5b13ffe1..fbe029cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,14 @@ -# Containerfile for PySceneDetect -# Copyright (C) 2026 FNGarvin. All rights reserved. -# License: BSD-3-Clause +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# FROM python:3.11.11-slim @@ -30,4 +38,3 @@ USER scenedetect # The default behavior is to run the CLI ENTRYPOINT ["scenedetect"] -# EOF Dockerfile From ebe45afe884d1bb82ba113dbe65ecf5c81a3086f Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 12 Apr 2026 19:00:48 -0400 Subject: [PATCH 047/163] Finalize VFR Support (#540) * [timecode] Finalize VFR support #168 * [timecode] Fix VFR timing, persistent decoder, and output command accuracy - Fix PyAV read() to reuse persistent decoder generator, preventing the last frame from being dropped at EOF due to B-frame buffer flush on GC - Fix _handle_eof() to seek by PTS seconds instead of frame number (which is now a CFR-equivalent approximation, not a decode count) - Fix get_timecode() to skip nearest-frame snapping for Timecode-backed FrameTimecodes, so scene boundary timecodes are PTS-accurate for VFR - Fix FlashFilter to cache min_scene_len threshold in seconds from first frame's framerate, avoiding incorrect thresholds when OpenCV reports wrong average fps - Fix FCP7 XML: use seconds*fps for frame numbers, dynamic NTSC flag - Fix OTIO: use seconds*frame_rate for RationalTime values - Add $START_PTS and $END_PTS (ms) to split-video filename templates - Refactor test_vfr.py: use open_video(), add EXPECTED_SCENES_VFR ground truth, parameterize scene detection test for both pyav and opencv backends * [save-images] Fix VFR seek accuracy for OpenCV and image position generation OpenCV's CAP_PROP_POS_FRAMES does not map linearly to time in VFR video (e.g. at the same timestamp, PyAV and OpenCV report frame indices that differ by 35+ frames), causing thumbnails to land in the wrong scene. Two fixes: 1. VideoStreamCv2.seek(): switch from CAP_PROP_POS_FRAMES to CAP_PROP_POS_MSEC for time-accurate seeking on both CFR and VFR video. Seeking one nominal frame before the target ensures the subsequent read() returns the frame at the target. 2. ImageSaver.generate_timecode_list(): rewrite to use seconds-based arithmetic instead of frame-number ranges. This avoids the frame_num approximation (round(seconds * avg_fps)) which gives wrong indices for VFR video. * [cli] Round OTIO rational time values to 10 microsecond precision * [backends] Fix OpenCV seeking with VFR videos * [tests] Cleanup test imports and move deprecated import tests to test_api * [tests] Expand VFR test coverage and rework CLI tests Use Click's CliRunner rather than subprocesses for CLI tests. Add CSV, EDL, and expand OTIO tests for VFR and compare that the OpenCV and PyAV backends return equal results for both CFR and VFR video. --- docs/cli/backends.rst | 6 + scenedetect/_cli/commands.py | 33 +- scenedetect/backends/moviepy.py | 22 +- scenedetect/backends/opencv.py | 90 ++--- scenedetect/backends/pyav.py | 47 ++- scenedetect/common.py | 105 ++--- scenedetect/detector.py | 25 +- scenedetect/detectors/threshold_detector.py | 53 +-- scenedetect/output/image.py | 69 ++-- scenedetect/output/video.py | 5 +- scenedetect/video_stream.py | 4 +- tests/conftest.py | 11 + tests/helpers.py | 38 ++ tests/test_api.py | 25 ++ tests/test_cli.py | 140 ++++--- tests/test_detectors.py | 9 - tests/test_output.py | 9 - tests/test_stats_manager.py | 2 - tests/test_timecode.py | 49 ++- tests/test_vfr.py | 401 ++++++++++++++++++++ website/pages/changelog.md | 8 +- 21 files changed, 859 insertions(+), 292 deletions(-) create mode 100644 tests/helpers.py create mode 100644 tests/test_vfr.py diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst index 8df76a58..2e28102b 100644 --- a/docs/cli/backends.rst +++ b/docs/cli/backends.rst @@ -21,6 +21,8 @@ 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 @@ -28,6 +30,8 @@ PyAV The `PyAV `_ backend (`av package `_) 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 `. @@ -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 `. diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 6813caa8..0003f30e 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -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" @@ -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"{context.video_stream.frame_rate}") + ElementTree.fromstring(f"{round(fps)}") ) - # 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 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 @@ -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, @@ -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. @@ -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, diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 14758952..6624cdbe 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -17,6 +17,7 @@ """ import typing as ty +from fractions import Fraction from logging import getLogger import cv2 @@ -24,7 +25,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode +from scenedetect.common import FrameTimecode, Timecode, framerate_to_fraction from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream @@ -83,9 +84,9 @@ def __init__( """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: - """Framerate in frames/sec.""" - return self._reader.fps + def frame_rate(self) -> Fraction: + """Framerate in frames/sec as a rational Fraction.""" + return framerate_to_fraction(self._reader.fps) @property def path(self) -> ty.Union[bytes, str]: @@ -135,7 +136,14 @@ def position(self) -> FrameTimecode: calling `read`. This will always return 0 (e.g. be equal to `base_timecode`) if no frames have been `read` yet.""" frame_number = max(self._frame_number - 1, 0) - return FrameTimecode(frame_number, self.frame_rate) + # Synthesize a Timecode from the frame count and rational framerate. + # MoviePy assumes CFR, so this is equivalent to frame-based timing. + # Use the framerate denominator as the time_base denominator for exact timing. + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = frame_number * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=fps) @property def position_ms(self) -> float: @@ -173,10 +181,6 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ValueError: `target` is not a valid value (i.e. it is negative). """ success = False - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. - raise NotImplementedError() - if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 298f0301..5401119a 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -27,7 +27,7 @@ import cv2 import numpy as np -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction from scenedetect.platform import get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, @@ -111,7 +111,7 @@ def __init__( self._cap: ty.Optional[cv2.VideoCapture] = ( None # Reference to underlying cv2.VideoCapture object. ) - self._frame_rate: ty.Optional[float] = None + self._frame_rate: ty.Optional[Fraction] = None # VideoCapture state self._has_grabbed = False @@ -144,7 +144,7 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: + def frame_rate(self) -> Fraction: assert self._frame_rate return self._frame_rate @@ -196,30 +196,25 @@ def aspect_ratio(self) -> float: @property def timecode(self) -> Timecode: - """Current position within stream as a Timecode. This is not frame accurate.""" + """Current position within stream as a Timecode.""" # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we - # use `CAP_PROP_POS_MSEC` instead, with a time base of 1/1000. Unfortunately this means that - # rounding errors will affect frame accuracy with this backend. - pts = self._cap.get(cv2.CAP_PROP_POS_MSEC) - time_base = Fraction(1, 1000) - return Timecode(pts=round(pts), time_base=time_base) + # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to + # avoid frame-boundary rounding errors at common framerates like 24000/1001. + ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + time_base = Fraction(1, 1000000) + return Timecode(pts=round(ms * 1000), time_base=time_base) @property def position(self) -> FrameTimecode: - # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or - # add a config option before landing this. - if _USE_PTS_IN_DEVELOPMENT: - timecode = self.timecode - # If PTS is 0 but we've read frames, derive from frame number. - # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. - if timecode.pts == 0 and self.frame_number > 0: - time_sec = (self.frame_number - 1) / self.frame_rate - pts = round(time_sec * 1000) - timecode = Timecode(pts=pts, time_base=Fraction(1, 1000)) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) - if self.frame_number < 1: - return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + timecode = self.timecode + # If PTS is 0 but we've read frames, derive from frame number. + # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. + if timecode.pts == 0 and self.frame_number > 0: + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = (self.frame_number - 1) * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) @property def position_ms(self) -> float: @@ -235,23 +230,32 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if target < 0: raise ValueError("Target seek position cannot be negative!") - # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. - # Have to seek one behind and call grab() after to that the VideoCapture - # returns a valid timestamp when using CAP_PROP_POS_MSEC. - target_frame_cv2 = (self.base_timecode + target).frame_num - if target_frame_cv2 > 0: - target_frame_cv2 -= 1 - self._cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame_cv2) + target_secs = (self.base_timecode + target).seconds self._has_grabbed = False - # Preemptively grab the frame behind the target position if possible. - if target > 0: + if target_secs > 0: + # Seek one frame before target so the next read() returns the frame at target. + one_frame_ms = 1000.0 / float(self._frame_rate) + seek_ms = max(0.0, target_secs * 1000.0 - one_frame_ms) + self._cap.set(cv2.CAP_PROP_POS_MSEC, seek_ms) self._has_grabbed = self._cap.grab() - # If we seeked past the end of the video, need to seek one frame backwards - # from the current position and grab that frame instead. + if self._has_grabbed: + # VFR correction: set(CAP_PROP_POS_MSEC) converts time using avg_fps internally, + # which can land ~1s too early for VFR video. Read forward until we reach the + # intended position. The threshold (2x one_frame_ms) never triggers for CFR. + actual_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + corrections = 0 + while actual_ms < seek_ms - 2.0 * one_frame_ms and corrections < 100: + if not self._cap.grab(): + break + actual_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + corrections += 1 + # If we seeked past the end, back up one frame. if not self._has_grabbed: seek_pos = round(self._cap.get(cv2.CAP_PROP_POS_FRAMES) - 1.0) self._cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, seek_pos)) self._has_grabbed = self._cap.grab() + else: + self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0) def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" @@ -329,14 +333,11 @@ def _open_capture(self, framerate: ty.Optional[float] = None): raise FrameRateUnavailable() self._cap = cap - self._frame_rate = framerate + self._frame_rate = framerate_to_fraction(framerate) self._has_grabbed = False cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 -# TODO(https://scenedetect.com/issues/168): Support non-monotonic timing for `position`. VFR timecode -# support is a prerequisite for this. Timecodes are currently calculated by multiplying the -# framerate by number of frames. Actual elapsed time can be obtained via `position_ms` for now. class VideoCaptureAdapter(VideoStream): """Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports VideoCaptures which may not support seeking. @@ -378,7 +379,7 @@ def __init__( raise FrameRateUnavailable() self._cap = cap - self._frame_rate: float = framerate + self._frame_rate: Fraction = framerate_to_fraction(framerate) self._num_frames = 0 self._max_read_attempts = max_read_attempts self._decode_failures = 0 @@ -408,7 +409,7 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: + def frame_rate(self) -> Fraction: """Framerate in frames/sec.""" assert self._frame_rate return self._frame_rate @@ -439,8 +440,6 @@ def frame_size(self) -> ty.Tuple[int, int]: @property def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - # TODO(https://scenedetect.com/issue/168): This will be incorrect for VFR. See if there is - # another property we can use to estimate the video length correctly. frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: return self.base_timecode + frame_count @@ -455,7 +454,12 @@ def aspect_ratio(self) -> float: def position(self) -> FrameTimecode: if self.frame_number < 1: return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + # Synthesize a Timecode from frame count and rational framerate. + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = (self.frame_number - 1) * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=fps) @property def position_ms(self) -> float: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 8692cdb5..a1ade9b4 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -18,7 +18,7 @@ import av import numpy as np -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode from scenedetect.platform import get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream @@ -81,6 +81,8 @@ def __init__( self._name = "" if name is None else name self._path = "" self._frame: ty.Optional[av.VideoFrame] = None + self._decoder: ty.Optional[ty.Generator] = None + self._decode_count: int = 0 self._reopened = True if threading_mode: @@ -172,8 +174,8 @@ def duration(self) -> FrameTimecode: return self.base_timecode + self._duration_frames @property - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction.""" return self._frame_rate @property @@ -184,10 +186,8 @@ def position(self) -> FrameTimecode: to the presentation time 0. Returns 0 even if `frame_number` is 1.""" if self._frame is None: return self.base_timecode - if _USE_PTS_IN_DEVELOPMENT: - timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) - return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate) + timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) @property def position_ms(self) -> float: @@ -199,16 +199,13 @@ def position_ms(self) -> float: @property def frame_number(self) -> int: - """Current position within stream as the frame number. + """Current position within stream as the frame number (CFR-equivalent). - Will return 0 until the first frame is `read`.""" - - if self._frame: - if _USE_PTS_IN_DEVELOPMENT: - # frame_number is 1-indexed, so add 1 to the 0-based frame position. - return round(self._frame.time * self.frame_rate) + 1 - return self.position.frame_num + 1 - return 0 + Will return 0 until the first frame is `read`. For VFR video this is an approximation + derived from PTS × framerate; use `position` for accurate PTS-based timing.""" + if self._frame is None: + return 0 + return round(self._frame.time * float(self.frame_rate)) + 1 @property def rate(self) -> Fraction: @@ -258,7 +255,6 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: raise ValueError("Target cannot be negative!") beginning = target == 0 - # TODO(https://scenedetect.com/issues/168): This breaks with PTS mode enabled. target = self.base_timecode + target if target >= 1: target = target - 1 @@ -266,6 +262,8 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: (self.base_timecode + target).seconds / self._video_stream.time_base ) self._frame = None + self._decoder = None + self._decode_count = 0 self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -277,15 +275,23 @@ def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._container.close() self._frame = None + self._decoder = None + self._decode_count = 0 try: self._container = av.open(self._path if self._path else self._io) except Exception as ex: raise VideoOpenFailure() from ex def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + # Reuse a persistent decoder generator so the codec's internal frame buffer (used for + # B-frame reordering) is never flushed prematurely. Creating a new generator each call + # caused the last buffered frame to be lost at EOF. + if self._decoder is None: + self._decoder = self._container.decode(video=0) try: last_frame = self._frame - self._frame = next(self._container.decode(video=0)) + self._frame = next(self._decoder) + self._decode_count += 1 except av.error.EOFError: self._frame = last_frame if self._handle_eof(): @@ -350,7 +356,7 @@ def _handle_eof(self): # Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode. if not self.is_seekable or self._video_stream.thread_type not in ("AUTO", "FRAME"): return False - last_frame = self.frame_number + last_pos_secs = self.position.seconds orig_pos = self._io.tell() try: self._io.seek(0) @@ -360,5 +366,6 @@ def _handle_eof(self): raise self._container.close() self._container = container - self.seek(last_frame) + self._decoder = None + self.seek(last_pos_secs) return True diff --git a/scenedetect/common.py b/scenedetect/common.py index e4ab7e48..545ef7df 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -70,10 +70,6 @@ import cv2 -# TODO(https://scenedetect.com/issue/168): Ensure both CFR and VFR videos work as intended with this -# flag enabled. When this feature is stable, we can then work on a roll-out plan. -_USE_PTS_IN_DEVELOPMENT = False - ## ## Type Aliases ## @@ -99,6 +95,34 @@ _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE _MINUTES_PER_HOUR = 60.0 +# Common framerates mapped from their float representation to exact rational values. +_COMMON_FRAMERATES: ty.Dict[Fraction, Fraction] = { + Fraction(24000, 1001): Fraction(24000, 1001), # 23.976... + Fraction(30000, 1001): Fraction(30000, 1001), # 29.97... + Fraction(60000, 1001): Fraction(60000, 1001), # 59.94... + Fraction(120000, 1001): Fraction(120000, 1001), # 119.88... +} + + +def framerate_to_fraction(fps: float) -> Fraction: + """Convert a float framerate to an exact rational Fraction. + + Recognizes common NTSC framerates (23.976, 29.97, 59.94, 119.88) and maps them to their + exact rational representation (e.g. 24000/1001). For other values, uses limit_denominator + to find a clean rational approximation, or returns the exact integer fraction for whole + number framerates. + """ + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + # Integer framerates are exact. + if fps == int(fps): + return Fraction(int(fps), 1) + # Check against known common framerates using limit_denominator to find the closest match. + candidate = Fraction(fps).limit_denominator(10000) + if candidate in _COMMON_FRAMERATES: + return _COMMON_FRAMERATES[candidate] + return candidate + class Interpolation(Enum): """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" @@ -115,24 +139,6 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -# TODO(@Breakthrough): How should we deal with frame numbers when we have a `Timecode`? -# -# Each backend has slight nuances we have to take into account: -# - PyAV: Does not include a position in frames, we can probably estimate it. Need to also compare -# with how OpenCV handles this. It also seems to fail to decode the last frame. This library -# provides the most accurate timing information however. -# - OpenCV: Lacks any kind of timebase, only provides position in milliseconds and as frames. -# This is probably sufficient, since we could just use 1ms as a timebase. -# - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is -# probably not feasible, so we should make sure the docs warn users about this. -# -# In the meantime, having backends provide accurate timing information is controlled by a hard-coded -# constant `_USE_PTS_IN_DEVELOPMENT` in each backend implementation that supports it. It still does -# not work correctly however, as we have to modify detectors themselves to work with FrameTimecode -# objects instead of integer frame numbers like they do now. -# -# We might be able to avoid changing the detector interface if we just have them work directly with -# PTS and convert them back to FrameTimecodes with the same time base. @dataclass(frozen=True) class Timecode: """Timing information associated with a given frame.""" @@ -242,16 +248,9 @@ def __init__( @property def frame_num(self) -> ty.Optional[int]: - """The frame number. This value will be an estimate if the video is VFR. Prefer using the - `pts` property.""" + """The frame number. For VFR video or Timecode-backed objects, this is an approximation + based on the average framerate. Prefer using `pts` and `time_base` for precise timing.""" if isinstance(self._time, Timecode): - # We need to audit anything currently using this property to guarantee temporal - # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). - warnings.warn( - message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", - stacklevel=2, - category=UserWarning, - ) # Calculate approximate frame number from seconds and framerate. if self._rate is not None: return round(self._time.seconds * float(self._rate)) @@ -312,7 +311,6 @@ def get_framerate(self) -> float: ) return self.framerate - # TODO(https://scenedetect.com/issue/168): Figure out how to deal with VFR here. def equal_framerate(self, fps) -> bool: """Equal Framerate: Determines if the passed framerate is equal to that of this object. @@ -323,8 +321,8 @@ def equal_framerate(self, fps) -> bool: bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. """ - # TODO(https://scenedetect.com/issue/168): Support this comparison in the case FPS is not - # set but a timecode is. + if self.framerate is None: + return False return math.fabs(self.framerate - fps) < MAX_FPS_DELTA @property @@ -377,7 +375,10 @@ def get_timecode( str: The current time in the form ``"HH:MM:SS[.nnn]"``. """ # Compute hours and minutes based off of seconds, and update seconds. - if nearest_frame and self.framerate: + # For PTS-backed timecodes, the PTS already represents an exact frame boundary, so we use + # `seconds` directly. For non-PTS timecodes, `nearest_frame` snaps to the nearest frame + # boundary using frame_num, which avoids floating point drift in CFR video display. + if nearest_frame and self.framerate and not isinstance(self._time, Timecode): secs = self.frame_num / self.framerate else: secs = self.seconds @@ -566,12 +567,17 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base != other._time.time_base: - raise ValueError("timecodes have different time bases") - self._time = Timecode( - pts=max(0, self._time.pts + other._time.pts), - time_base=self._time.time_base, - ) + if self._time.time_base == other._time.time_base: + self._time = Timecode( + pts=max(0, self._time.pts + other._time.pts), + time_base=self._time.time_base, + ) + return self + # Different time bases: use the finer (smaller) one for better precision. + time_base = min(self._time.time_base, other._time.time_base) + self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) + other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + self._time = Timecode(pts=max(0, self_pts + other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a @@ -613,12 +619,17 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base != other._time.time_base: - raise ValueError("timecodes have different time bases") - self._time = Timecode( - pts=max(0, self._time.pts - other._time.pts), - time_base=self._time.time_base, - ) + if self._time.time_base == other._time.time_base: + self._time = Timecode( + pts=max(0, self._time.pts - other._time.pts), + time_base=self._time.time_base, + ) + return self + # Different time bases: use the finer (smaller) one for better precision. + time_base = min(self._time.time_base, other._time.time_base) + self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) + other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + self._time = Timecode(pts=max(0, self_pts - other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a diff --git a/scenedetect/detector.py b/scenedetect/detector.py index e7d9e731..7c3e1b70 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -122,6 +122,7 @@ def __init__(self, mode: Mode, length: int): """ self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. + self._filter_secs: ty.Optional[float] = None # Threshold in seconds, computed on first use. self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. @@ -143,9 +144,12 @@ def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[Fram raise RuntimeError("Unhandled FlashFilter mode.") def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: - framerate = timecode.framerate - assert framerate >= 0 - min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) + assert timecode.framerate >= 0 + # Compute the threshold in seconds once from the first frame's framerate. This avoids + # using an incorrect average fps (e.g. OpenCV on VFR video) on subsequent frames. + if self._filter_secs is None: + self._filter_secs = self._filter_length / timecode.framerate + min_length_met: bool = (timecode - self._last_above) >= self._filter_secs if not (above_threshold and min_length_met): return [] # Both length and threshold requirements were satisfied. Emit the cut, and wait until both @@ -154,16 +158,21 @@ def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty return [timecode] def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: - framerate = timecode.framerate - assert framerate >= 0 - min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) + assert timecode.framerate >= 0 + # Compute the threshold in seconds once from the first frame's framerate. + if self._filter_secs is None: + self._filter_secs = self._filter_length / timecode.framerate + min_length_met: bool = (timecode - self._last_above) >= self._filter_secs # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. - num_merged_frames = self._last_above - self._merge_start - if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + if ( + min_length_met + and not above_threshold + and (self._last_above - self._merge_start) >= self._filter_secs + ): self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 2cfe05b8..8d28cd62 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -88,7 +88,7 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - "frame": 0, # frame number where the last detected fade is + "frame": None, # FrameTimecode where the last detected fade is "type": None, # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] @@ -100,40 +100,29 @@ def get_metrics(self) -> ty.List[str]: def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray ) -> ty.List[FrameTimecode]: - """Process the next frame. `frame_num` is assumed to be sequential. + """Process the next frame. Args: - frame_num (int): Frame number of frame that is being passed. Can start from any value - but must remain sequential. - frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. + timecode: FrameTimecode of the current frame position. + frame_img (numpy.ndarray or None): Video frame corresponding to `timecode`. Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. + List of FrameTimecodes where scene cuts have been detected. """ - # TODO(https://scenedetect.com/issue/168): We need to consider PTS here instead. The methods below using frame numbers - # won't work for variable framerates. - frame_num = timecode.frame_num - # Initialize last scene cut point at the beginning of the frames of interest. if self.last_scene_cut is None: - self.last_scene_cut = frame_num - - # Compare the # of pixels under threshold in current_frame & last_frame. - # If absolute value of pixel intensity delta is above the threshold, - # then we trigger a new scene cut/break. + self.last_scene_cut = timecode - # List of cuts to return. - cuts = [] + cuts: ty.List[FrameTimecode] = [] # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this # frame metric instead (to assist with manually selecting a threshold) if (self.stats_manager is not None) and ( - self.stats_manager.metrics_exist(frame_num, self._metric_keys) + self.stats_manager.metrics_exist(timecode, self._metric_keys) ): - frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] + frame_avg = self.stats_manager.get_metrics(timecode, self._metric_keys)[0] else: frame_avg = numpy.mean(frame_img) if self.stats_manager is not None: @@ -146,32 +135,31 @@ def process_frame( ): # Just faded out of a scene, wait for next fade in. self.last_fade["type"] = "out" - self.last_fade["frame"] = frame_num + self.last_fade["frame"] = timecode elif self.last_fade["type"] == "out" and ( (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold) ): # Only add the scene if min_scene_len frames have passed. - if (frame_num - self.last_scene_cut) >= self.min_scene_len: + if (timecode - self.last_scene_cut) >= self.min_scene_len: # Just faded into a new scene, compute timecode for the scene # split based on the fade bias. f_out = self.last_fade["frame"] - f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2 - ) - cuts.append(f_split) - self.last_scene_cut = frame_num + duration = (timecode - f_out).seconds + split_seconds = f_out.seconds + (duration * (1.0 + self.fade_bias)) / 2.0 + cuts.append(FrameTimecode(split_seconds, fps=timecode)) + self.last_scene_cut = timecode self.last_fade["type"] = "in" - self.last_fade["frame"] = frame_num + self.last_fade["frame"] = timecode else: - self.last_fade["frame"] = 0 + self.last_fade["frame"] = timecode if frame_avg < self.threshold: self.last_fade["type"] = "out" else: self.last_fade["type"] = "in" self.processed_frame = True - return [FrameTimecode(cut, fps=timecode) for cut in cuts] + return cuts def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out. @@ -185,14 +173,15 @@ def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cuts = [] + cuts: ty.List[FrameTimecode] = [] if ( self.last_fade["type"] == "out" and self.add_final_scene + and self.last_fade["frame"] is not None and ( (self.last_scene_cut is None and timecode >= self.min_scene_len) or (timecode - self.last_scene_cut) >= self.min_scene_len ) ): cuts.append(self.last_fade["frame"]) - return [FrameTimecode(cut, fps=timecode) for cut in cuts] + return cuts diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 3fa54b04..d5cf00de 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -290,48 +290,37 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): if progress_bar is not None: progress_bar.update(1) - def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: + def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.List[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config - parameters.""" - # TODO(v0.7): This needs to be fixed as part of PTS overhaul. + parameters. + + Uses PTS-accurate seconds-based timing so results are correct for both CFR and VFR video. + """ framerate = scene_list[0][0].framerate - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - return [ - ( - FrameTimecode(int(f), fps=framerate) - for f in ( - # middle frames - a[len(a) // 2] - if (0 < j < self._num_images - 1) or self._num_images == 1 - # first frame - else min(a[0] + self._frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - self._frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, self._num_images)) - ) - ) - for r in ( - # pad ranges to number of images - r - if 1 + r[-1] - r[0] >= self._num_images - else list(r) + [r[-1]] * (self._num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.frame_num, - start.frame_num - + max( - 1, # guard against zero length scenes - end.frame_num - start.frame_num, - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ) - ] + # Convert frame_margin to seconds using the nominal framerate. + margin_secs = self._frame_margin / framerate + result = [] + for start, end in scene_list: + duration_secs = (end - start).seconds + if duration_secs <= 0: + result.append([start] * self._num_images) + continue + segment_secs = duration_secs / self._num_images + timecodes = [] + for j in range(self._num_images): + seg_start = start.seconds + j * segment_secs + seg_end = start.seconds + (j + 1) * segment_secs + if self._num_images == 1: + t = start.seconds + duration_secs / 2.0 + elif j == 0: + t = min(seg_start + margin_secs, seg_end) + elif j == self._num_images - 1: + t = max(seg_end - margin_secs, seg_start) + else: + t = (seg_start + seg_end) / 2.0 + timecodes.append(FrameTimecode(t, fps=framerate)) + result.append(timecodes) + return result def resize_image( self, diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 737cb92d..07b01c1b 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -125,7 +125,8 @@ class SceneMetadata: def default_formatter(template: str) -> PathFormatter: """Formats filenames using a template string which allows the following variables: - `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME`, + `$START_PTS`, `$END_PTS` (presentation timestamp in milliseconds, accurate for VFR video) """ MIN_DIGITS = 3 format_scene_number: PathFormatter = lambda video, scene: ( @@ -139,6 +140,8 @@ def default_formatter(template: str) -> PathFormatter: END_TIME=str(scene.end.get_timecode().replace(":", ";")), START_FRAME=str(scene.start.frame_num), END_FRAME=str(scene.end.frame_num), + START_PTS=str(round(scene.start.seconds * 1000)), + END_PTS=str(round(scene.end.seconds * 1000)), ) return formatter diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 26c2cefe..18a04b2c 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -124,8 +124,8 @@ def is_seekable(self) -> bool: @property @abstractmethod - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction (e.g. Fraction(24000, 1001)).""" ... @property diff --git a/tests/conftest.py b/tests/conftest.py index 25bf517e..8cef4b0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,17 @@ def test_vfr_video() -> str: return check_exists("tests/resources/goldeneye-vfr.mp4") +@pytest.fixture +def test_vfr_drop3_video() -> str: + """Synthetic VFR video created from goldeneye.mp4 by dropping every 3rd frame. + + Frame pattern: keeps frames where (n+1) % 3 != 0 (i.e. drops frames 2,5,8,...). + Resulting PTS durations alternate: 1001, 2002, 1001, 2002, ... (time_base=1/24000). + Nominal fps: 24000/1001. Average fps: ~16 fps. Duration: ~10s, 160 frames. + """ + return check_exists("tests/resources/goldeneye-vfr-drop3.mp4") + + @pytest.fixture def corrupt_video_file() -> str: """Video containing a corrupted frame causing a decode failure.""" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..4bdb6d7c --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,38 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Shared test helpers.""" + +import typing as ty + +from click.testing import CliRunner + +from scenedetect._cli import scenedetect as _scenedetect_cli +from scenedetect._cli.context import CliContext +from scenedetect._cli.controller import run_scenedetect + + +def invoke_cli(args: ty.List[str], catch_exceptions: bool = False) -> ty.Tuple[int, str]: + """Invoke the scenedetect CLI in-process using Click's CliRunner. + + Replicates the two-step execution of ``__main__.py``: + + 1. ``scenedetect.main(obj=context)`` — parse args and register callbacks on ``CliContext`` + 2. ``run_scenedetect(context)`` — execute detection and output commands + + Returns ``(exit_code, output_text)``. + """ + context = CliContext() + runner = CliRunner() + result = runner.invoke(_scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions) + if result.exit_code == 0: + run_scenedetect(context) + return result.exit_code, result.output diff --git a/tests/test_api.py b/tests/test_api.py index e86243ad..853b7c32 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -142,3 +142,28 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video, duration=total_frames, callback=on_new_scene) + + +# TODO(v0.8): Remove this test when these deprecated modules are removed from the codebase. +def test_deprecated_modules_emits_warning_on_import(): + import importlib + + import pytest + + SCENE_DETECTOR_WARNING = ( + "The `scene_detector` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): + importlib.import_module("scenedetect.scene_detector") + + FRAME_TIMECODE_WARNING = ( + "The `frame_timecode` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): + importlib.import_module("scenedetect.frame_timecode") + + VIDEO_SPLITTER_WARNING = ( + "The `video_splitter` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): + importlib.import_module("scenedetect.video_splitter") diff --git a/tests/test_cli.py b/tests/test_cli.py index 29fd2738..3f77e91f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,37 +12,36 @@ import os import subprocess -import typing as ty -from pathlib import Path - -import cv2 -import numpy as np -import pytest - -import scenedetect -from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available # These tests validate that the CLI itself functions correctly, mainly based on the return # return code from the process. We do not yet check for correctness of the output, just a # successful invocation of the command (i.e. no exceptions/errors). - # TODO: Add some basic correctness tests to validate the output (just look for the # last expected log message or extract # of scenes). Might need to refactor the test cases # since we need to calculate the output file names for commands that write to disk. - # TODO: Define error/exit codes explicitly. Right now these tests only verify that the # exit code is zero or nonzero. - # TODO: These tests are very expensive since they spin up new Python interpreters. # Move most of these test cases (e.g. argument validation) to ones that interface directly # with the scenedetect._cli module. Click also supports unit testing directly, so we should # probably use that instead of spinning up new subprocesses for each run of the controller. # That will also allow splitting up the validation of argument parsing logic from the controller # logic by creating a CLI context with the desired parameters. - # TODO: Missing tests for --min-scene-len and --drop-short-scenes. +import sys +import typing as ty +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +import scenedetect +from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available +from tests.helpers import invoke_cli + +SCENEDETECT_CMD = sys.executable + " -m scenedetect" -SCENEDETECT_CMD = "python -m scenedetect" ALL_DETECTORS = [ "detect-content", "detect-threshold", @@ -305,14 +304,22 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" - # Regular invocation - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "list-scenes", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") assert os.path.exists(output_path) EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754 @@ -744,13 +751,22 @@ def test_cli_load_scenes_round_trip(): def test_cli_save_edl(tmp_path: Path): """Test `save-edl` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-edl", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-edl", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.edl") assert os.path.exists(output_path) EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} @@ -765,13 +781,28 @@ def test_cli_save_edl(tmp_path: Path): def test_cli_save_edl_with_params(tmp_path: Path): """Test `save-edl` command but override the other options.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-edl -t title -r BX -f file_no_ext", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-edl", + "-t", + "title", + "-r", + "BX", + "-f", + "file_no_ext", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath("file_no_ext") assert os.path.exists(output_path) EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} @@ -786,13 +817,22 @@ def test_cli_save_edl_with_params(tmp_path: Path): def test_cli_save_otio(tmp_path: Path): """Test `save-otio` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-otio", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-otio", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") assert os.path.exists(output_path) EXPECTED_OTIO_OUTPUT = """{ @@ -994,13 +1034,23 @@ def test_cli_save_otio(tmp_path: Path): def test_cli_save_otio_no_audio(tmp_path: Path): """Test `save-otio` command without audio.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-otio --no-audio", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-otio", + "--no-audio", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") assert os.path.exists(output_path) EXPECTED_OTIO_OUTPUT = """{ diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 445112ee..0e5f4214 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -224,12 +224,3 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len - - -# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. -def test_deprecated_detector_module_emits_warning_on_import(): - SCENE_DETECTOR_WARNING = ( - "The `scene_detector` submodule is deprecated, import from the base package instead." - ) - with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): - from scenedetect.scene_detector import SceneDetector as _ diff --git a/tests/test_output.py b/tests/test_output.py index bc1762e5..db3f2307 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -191,12 +191,3 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): total_images += 1 assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - - -# TODO(v0.8): Remove this test during the removal of `scenedetect.video_splitter`. -def test_deprecated_output_modules_emits_warning_on_import(): - VIDEO_SPLITTER_WARNING = ( - "The `video_splitter` submodule is deprecated, import from the base package instead." - ) - with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): - from scenedetect.video_splitter import split_video_ffmpeg as _ diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 0c32371e..03bef2c7 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -27,8 +27,6 @@ """ import csv -import os -import random from pathlib import Path import pytest diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 4d6c0d21..f4296923 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -26,7 +26,7 @@ import pytest # Standard Library Imports -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction def test_framerate(): @@ -299,10 +299,43 @@ def test_precision(): assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" -# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. -def test_deprecated_timecode_module_emits_warning_on_import(): - FRAME_TIMECODE_WARNING = ( - "The `frame_timecode` submodule is deprecated, import from the base package instead." - ) - with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): - from scenedetect.frame_timecode import FrameTimecode as _ +def test_rational_framerate_precision(): + """Rational framerates should round-trip frame/second conversions without drift.""" + fps = Fraction(24000, 1001) + # Verify that frame_num round-trips through seconds without drift over many frames. + for frame in [0, 1, 100, 1000, 10000, 100000]: + tc = FrameTimecode(frame, fps) + assert tc.frame_num == frame, f"Frame {frame} drifted to {tc.frame_num}" + + +def test_ntsc_framerate_detection(): + """Common NTSC framerates should be detected from float values.""" + assert framerate_to_fraction(23.976023976023978) == Fraction(24000, 1001) + assert framerate_to_fraction(29.97002997002997) == Fraction(30000, 1001) + assert framerate_to_fraction(59.94005994005994) == Fraction(60000, 1001) + assert framerate_to_fraction(24.0) == Fraction(24, 1) + assert framerate_to_fraction(30.0) == Fraction(30, 1) + assert framerate_to_fraction(60.0) == Fraction(60, 1) + assert framerate_to_fraction(25.0) == Fraction(25, 1) + + +def test_timecode_arithmetic_mixed_time_base(): + """Arithmetic with FrameTimecodes using different time_bases should work.""" + fps = Fraction(24000, 1001) + # Timecode with time_base 1/24000 (from PyAV) + tc_pyav = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # Timecode with time_base 1/1000000 (from OpenCV microseconds) + tc_cv2 = FrameTimecode(timecode=Timecode(pts=41708, time_base=Fraction(1, 1000000)), fps=fps) + # Both represent approximately 1 frame duration. Addition/subtraction shouldn't raise. + result = tc_pyav + tc_cv2 + assert result.seconds > 0 + result = tc_pyav - tc_cv2 + assert result.seconds >= 0 # Clamped to 0 if negative + + +def test_timecode_frame_num_for_vfr(): + """frame_num should return approximate values for Timecode-backed objects without warning.""" + fps = Fraction(24000, 1001) + tc = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # Should not raise or warn - just return the approximate frame number. + assert tc.frame_num == 1 diff --git a/tests/test_vfr.py b/tests/test_vfr.py new file mode 100644 index 00000000..09aab163 --- /dev/null +++ b/tests/test_vfr.py @@ -0,0 +1,401 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tests for VFR (Variable Frame Rate) video support.""" + +import csv +import json +import os +import typing as ty + +import cv2 +import numpy as np +import pytest + +from scenedetect import SceneManager, open_video +from scenedetect.common import Timecode +from scenedetect.detectors import ContentDetector +from scenedetect.output import save_images, write_scene_list +from scenedetect.stats_manager import StatsManager +from tests.helpers import invoke_cli + +# Expected scene cuts for `goldeneye-vfr.mp4` detected with ContentDetector() and end_time=10.0s. +# Entries are (start_timecode, end_timecode). All backends should agree on cut timecodes since +# CAP_PROP_POS_MSEC gives accurate PTS-derived timestamps. The last scene ends at the clip +# boundary (end_time) which may vary slightly between backends based on frame counting. +EXPECTED_SCENES_VFR: ty.List[ty.Tuple[str, str]] = [ + ("00:00:00.000", "00:00:03.921"), + ("00:00:03.921", "00:00:09.676"), +] + +# Expected scene cuts for `goldeneye-vfr-drop3.mp4` — a synthetic VFR clip created from the first +# 10s of goldeneye.mp4 by dropping every 3rd frame (frames 2,5,8,...). PTS durations alternate +# between 1001 and 2002 (time_base=1/24000), nominal fps=24000/1001, avg fps≈16. The last scene +# ends at the clip boundary and may vary slightly between backends. +EXPECTED_SCENES_VFR_DROP3: ty.List[ty.Tuple[str, str]] = [ + ("00:00:00.000", "00:00:03.754"), + ("00:00:03.754", "00:00:08.759"), +] + + +def _tc_to_secs(tc: str) -> float: + """Parse a HH:MM:SS.mmm timecode string to seconds.""" + h, m, rest = tc.split(":") + s, ms = rest.split(".") + return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000 + + +def test_vfr_position_is_timecode(test_vfr_video: str): + """Position should be a Timecode-backed FrameTimecode.""" + video = open_video(test_vfr_video, backend="pyav") + assert video.read() is not False + assert isinstance(video.position._time, Timecode) + + +def test_vfr_position_monotonic_pyav(test_vfr_video: str): + """PTS-based position should be monotonically non-decreasing (PyAV).""" + video = open_video(test_vfr_video, backend="pyav") + last_seconds = -1.0 + frame_count = 0 + while True: + frame = video.read() + if frame is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count > 0 + + +def test_vfr_position_monotonic_opencv(test_vfr_video: str): + """PTS-based position should be monotonically non-decreasing (OpenCV).""" + video = open_video(test_vfr_video, backend="opencv") + last_seconds = -1.0 + frame_count = 0 + while True: + frame = video.read() + if frame is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count > 0 + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_scene_detection(test_vfr_video: str, backend: str): + """Scene detection on VFR video should produce timestamps matching known ground truth. + + Both PyAV (native PTS) and OpenCV (CAP_PROP_POS_MSEC) should agree on scene cuts since + both expose accurate PTS-derived timestamps. + """ + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + scene_list = sm.get_scene_list() + + # The last scene ends at the clip boundary which may vary by backend; only check known cuts. + assert len(scene_list) >= len(EXPECTED_SCENES_VFR), ( + f"[{backend}] Expected at least {len(EXPECTED_SCENES_VFR)} scenes, got {len(scene_list)}" + ) + for i, ((start, end), (exp_start_tc, exp_end_tc)) in enumerate( + zip(scene_list, EXPECTED_SCENES_VFR, strict=False) + ): + assert start.get_timecode() == exp_start_tc, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start_tc!r}, got {start.get_timecode()!r}" + ) + assert end.get_timecode() == exp_end_tc, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end_tc!r}, got {end.get_timecode()!r}" + ) + + +def test_vfr_seek_pyav(test_vfr_video: str): + """Seeking should work with VFR video.""" + video = open_video(test_vfr_video, backend="pyav") + target_time = 2.0 # seconds + video.seek(target_time) + frame = video.read() + assert frame is not False + # Position should be close to target (within 1 second for keyframe-based seeking). + assert abs(video.position.seconds - target_time) < 1.0 + + +def test_vfr_stats_manager(test_vfr_video: str): + """StatsManager should work correctly with VFR video.""" + video = open_video(test_vfr_video, backend="pyav") + stats = StatsManager() + sm = SceneManager(stats_manager=stats) + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + assert len(sm.get_scene_list()) > 0 + + +def test_vfr_csv_output(test_vfr_video: str, tmp_path): + """CSV export should work correctly with VFR video.""" + from scenedetect.output import write_scene_list + + video = open_video(test_vfr_video, backend="pyav") + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + scene_list = sm.get_scene_list() + assert len(scene_list) > 0 + + csv_path = os.path.join(str(tmp_path), "scenes.csv") + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list) + + # Verify CSV contains valid data. + with open(csv_path, "r") as f: + reader = csv.reader(f) + rows = list(reader) + assert len(rows) >= 3 # 2 header rows + data + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str): + """Synthetic VFR video (drop every 3rd frame, alternating 1x/2x durations) should produce + timecodes matching known ground truth with both backends.""" + video = open_video(test_vfr_drop3_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, show_progress=False) + scene_list = sm.get_scene_list() + + assert len(scene_list) >= len(EXPECTED_SCENES_VFR_DROP3), ( + f"[{backend}] Expected at least {len(EXPECTED_SCENES_VFR_DROP3)} scenes, got {len(scene_list)}" + ) + for i, ((start, end), (exp_start_tc, exp_end_tc)) in enumerate( + zip(scene_list, EXPECTED_SCENES_VFR_DROP3, strict=False) + ): + assert start.get_timecode() == exp_start_tc, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start_tc!r}, got {start.get_timecode()!r}" + ) + assert end.get_timecode() == exp_end_tc, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end_tc!r}, got {end.get_timecode()!r}" + ) + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str): + """PTS-based position should be monotonically non-decreasing on synthetic VFR video.""" + video = open_video(test_vfr_drop3_video, backend=backend) + last_seconds = -1.0 + frame_count = 0 + while True: + if video.read() is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"[{backend}] Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count == 160 # 2/3 of original 240 frames in 10s at 24000/1001 + + +def test_cfr_position_is_timecode(test_movie_clip: str): + """CFR video positions should also be Timecode-backed with PTS support.""" + video = open_video(test_movie_clip, backend="pyav") + assert video.read() is not False + assert isinstance(video.position._time, Timecode) + + +def test_cfr_frame_num_exact(test_movie_clip: str): + """For CFR video, frame_num should be exact (not approximate).""" + video = open_video(test_movie_clip, backend="pyav") + for expected_frame in range(1, 11): + assert video.read() is not False + assert video.position.frame_num == expected_frame - 1 + + +def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path): + """OpenCV save-images thumbnails should match PyAV thumbnails for all scenes. + + If the OpenCV seek off-by-one bug is present, scene thumbnails will show content from the + wrong scene; MSE against PyAV (ground truth) will be very high for those scenes. + """ + # Run save-images for both backends with 1 image per scene for simplicity. + scene_lists = {} + for backend in ("pyav", "opencv"): + out_dir = tmp_path / backend + out_dir.mkdir() + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + scene_lists[backend] = sm.get_scene_list() + assert len(scene_lists[backend]) > 0 + save_images(scene_lists[backend], video, num_images=1, output_dir=str(out_dir)) + + pyav_imgs = sorted((tmp_path / "pyav").glob("*.jpg")) + opencv_imgs = sorted((tmp_path / "opencv").glob("*.jpg")) + assert len(pyav_imgs) > 0 + assert len(pyav_imgs) == len(opencv_imgs), ( + f"Image count mismatch: pyav={len(pyav_imgs)}, opencv={len(opencv_imgs)}" + ) + + # Compare every corresponding thumbnail. Wrong-scene content produces very high MSE. + MAX_MSE = 5000 + for pyav_path, opencv_path in zip(pyav_imgs, opencv_imgs, strict=False): + img_pyav = cv2.imread(str(pyav_path)) + img_opencv = cv2.imread(str(opencv_path)) + assert img_pyav is not None, f"Failed to load {pyav_path}" + assert img_opencv is not None, f"Failed to load {opencv_path}" + if img_pyav.shape != img_opencv.shape: + # Resize opencv image to match pyav dimensions before comparing. + img_opencv = cv2.resize(img_opencv, (img_pyav.shape[1], img_pyav.shape[0])) + mse = float(np.mean((img_pyav.astype(np.float32) - img_opencv.astype(np.float32)) ** 2)) + assert mse < MAX_MSE, ( + f"Thumbnail mismatch for {pyav_path.name} vs {opencv_path.name}: MSE={mse:.0f}" + ) + + +# ------------------------------------------------------------------ +# Output format tests +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path): + """CSV timecodes for VFR video should match known ground truth for both backends.""" + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + scene_list = sm.get_scene_list() + assert len(scene_list) >= len(EXPECTED_SCENES_VFR) + + csv_path = tmp_path / "scenes.csv" + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list, include_cut_list=False) + + with open(csv_path) as f: + rows = list(csv.DictReader(f)) + + for i, (row, (exp_start, exp_end)) in enumerate(zip(rows, EXPECTED_SCENES_VFR, strict=False)): + assert row["Start Timecode"] == exp_start, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start!r}, got {row['Start Timecode']!r}" + ) + assert row["End Timecode"] == exp_end, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end!r}, got {row['End Timecode']!r}" + ) + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_otio_export(test_vfr_video: str, backend: str, tmp_path): + """OTIO export for VFR video should have no spurious float precision and correct timecodes. + + Regression test for the float precision bug where seconds * frame_rate could produce + values like 90.00000000000001 instead of 90.0 for CFR video. + """ + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-b", + backend, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-otio", + ] + ) + assert exit_code == 0 + + otio_path = next(tmp_path.glob("*.otio")) + data = json.loads(otio_path.read_text()) + frame_rate = data["global_start_time"]["rate"] + one_frame_secs = 1.0 / frame_rate + + clips = data["tracks"]["children"][0]["children"] + assert len(clips) >= len(EXPECTED_SCENES_VFR) + + for i, (clip, (exp_start_tc, exp_end_tc)) in enumerate( + zip(clips, EXPECTED_SCENES_VFR, strict=False) + ): + sr = clip["source_range"] + start_val = sr["start_time"]["value"] + dur_val = sr["duration"]["value"] + + # No spurious float precision: values should have at most 6 decimal places. + assert round(start_val, 6) == start_val, ( + f"[{backend}] Clip {i + 1} start_time.value has excess precision: {start_val!r}" + ) + assert round(dur_val, 6) == dur_val, ( + f"[{backend}] Clip {i + 1} duration.value has excess precision: {dur_val!r}" + ) + + # Values should round-trip to the expected timecodes within 1 frame. + start_secs = start_val / frame_rate + end_secs = (start_val + dur_val) / frame_rate + assert abs(start_secs - _tc_to_secs(exp_start_tc)) < one_frame_secs, ( + f"[{backend}] Clip {i + 1} start: {start_secs:.4f}s vs expected {exp_start_tc}" + ) + assert abs(end_secs - _tc_to_secs(exp_end_tc)) < one_frame_secs, ( + f"[{backend}] Clip {i + 1} end: {end_secs:.4f}s vs expected {exp_end_tc}" + ) + + +def test_vfr_edl_export(test_vfr_video: str, tmp_path): + """EDL export for VFR video should succeed and contain valid edit entries. + + EDL uses HH:MM:SS:FF frame counts at nominal fps, which is an approximation for VFR + content. This test only verifies structural correctness, not exact timecodes. + """ + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-edl", + ] + ) + assert exit_code == 0 + edl_path = next(tmp_path.glob("*.edl")) + content = edl_path.read_text() + assert "FCM: NON-DROP FRAME" in content + assert "001 AX V" in content + + +def test_vfr_csv_backend_conformance(test_vfr_video: str): + """PyAV and OpenCV should produce identical scene timecodes for VFR video. + + Only the known interior scenes are compared; the last scene's end time may vary slightly + between backends since it reflects the clip boundary rather than a detected cut. + """ + timecodes: ty.Dict[str, ty.List[ty.Tuple[str, str]]] = {} + for backend in ("pyav", "opencv"): + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + timecodes[backend] = [(s.get_timecode(), e.get_timecode()) for s, e in sm.get_scene_list()] + # Compare only the known scenes (last scene's end varies by backend at the clip boundary). + n = len(EXPECTED_SCENES_VFR) + assert timecodes["pyav"][:n] == timecodes["opencv"][:n], ( + f"Backend timecode mismatch:\n pyav: {timecodes['pyav']}\n opencv: {timecodes['opencv']}" + ) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index acfb9ffd..535e2d5a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -675,7 +675,9 @@ Although there have been minimal changes to most API examples, there are several ### CLI Changes -- [feature] [WIP] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters +- [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command ### API Changes @@ -723,5 +725,5 @@ Although there have been minimal changes to most API examples, there are several * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` - - + * Remove `SceneDetector.stats_manager_required` property, no longer required + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) From fda8c580834fa6de8efe2d0ce270e9fceef2016f Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 17:19:02 -0400 Subject: [PATCH 048/163] Use time-based units instead of frame-based for default parameters (#532) [api] Make sure all values are temporal --- scenedetect.cfg | 3 +- scenedetect/_cli/__init__.py | 10 +- scenedetect/_cli/config.py | 2 +- scenedetect/detector.py | 35 +++++- scenedetect/detectors/adaptive_detector.py | 7 +- scenedetect/detectors/content_detector.py | 7 +- scenedetect/detectors/hash_detector.py | 7 +- scenedetect/detectors/histogram_detector.py | 12 +- scenedetect/detectors/threshold_detector.py | 7 +- scenedetect/detectors/transnet_v2.py | 2 +- scenedetect/output/image.py | 127 ++++++++------------ tests/test_detectors.py | 22 ++++ tests/test_output.py | 32 +++++ website/pages/changelog.md | 2 + 14 files changed, 170 insertions(+), 105 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index fd6241cd..f0901cc0 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -227,7 +227,8 @@ # 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. +# Padding around each scene cut when selecting frames. Accepts a number of frames (1), +# seconds with `s` suffix (0.1s), or timecode (00:00:00.100). #frame-margin = 1 # Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double). diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index a0c639d2..88dc9654 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1397,11 +1397,11 @@ def split_video_command( @click.option( "-m", "--frame-margin", - metavar="N", + metavar="DURATION", 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, + help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).%s" + % (USER_CONFIG.get_help_string("save-images", "frame-margin")), ) @click.option( "--scale", @@ -1441,7 +1441,7 @@ def save_images_command( quality: ty.Optional[int] = None, png: bool = False, compression: ty.Optional[int] = None, - frame_margin: ty.Optional[int] = None, + frame_margin: ty.Optional[str] = None, scale: ty.Optional[float] = None, height: ty.Optional[int] = None, width: ty.Optional[int] = None, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index ee851da8..80fc082f 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -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, + "frame-margin": TimecodeValue(1), "height": 0, "num-images": 3, "output": None, diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 7c3e1b70..8afd0f71 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -24,6 +24,7 @@ event (in, out, cut, etc...). """ +import math import typing as ty from abc import ABC, abstractmethod from enum import Enum @@ -114,15 +115,26 @@ class Mode(Enum): SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" - def __init__(self, mode: Mode, length: int): + def __init__(self, mode: Mode, length: ty.Union[int, float, str]): """ Arguments: mode: The mode to use when enforcing `length`. - length: Number of frames to use when filtering cuts. + length: Minimum scene length. Accepts an `int` (number of frames), `float` (seconds), + or `str` (timecode, e.g. ``"0.6s"`` or ``"00:00:00.600"``). """ self._mode = mode - self._filter_length = length # Number of frames to use for activating the filter. - self._filter_secs: ty.Optional[float] = None # Threshold in seconds, computed on first use. + # Frame count (int) and seconds (float) representations of `length`. Exactly one is + # populated up front; the other is computed on the first frame once the framerate is + # known. Temporal inputs (float/non-digit str) populate `_filter_secs`; integer inputs + # (int/digit str) populate `_filter_length`. + self._filter_length: int = 0 + self._filter_secs: ty.Optional[float] = None + if isinstance(length, float): + self._filter_secs = length + elif isinstance(length, str) and not length.strip().isdigit(): + self._filter_secs = FrameTimecode(timecode=length, fps=100.0).seconds + else: + self._filter_length = int(length) self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. @@ -130,10 +142,21 @@ def __init__(self, mode: Mode, length: int): @property def max_behind(self) -> int: - return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length + if self._mode == FlashFilter.Mode.SUPPRESS: + return 0 + if self._filter_secs is not None: + # Estimate using 240fps so the event buffer is large enough for any reasonable input. + return math.ceil(self._filter_secs * 240.0) + return self._filter_length + + @property + def _is_disabled(self) -> bool: + if self._filter_secs is not None: + return self._filter_secs <= 0.0 + return self._filter_length <= 0 def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: - if not self._filter_length > 0: + if self._is_disabled: return [timecode] if above_threshold else [] if self._last_above is None: self._last_above = timecode diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 7a0a23af..f1917d77 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -38,7 +38,7 @@ class AdaptiveDetector(ContentDetector): def __init__( self, adaptive_threshold: float = 3.0, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, window_width: int = 2, min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, @@ -49,8 +49,9 @@ def __init__( Arguments: adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a new scene (see frame metric adaptive_ratio in stats file). - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). window_width: Size of window (number of frames) before and after each frame to average together in order to detect deviations from the mean. Must be at least 1. min_content_val: Minimum threshold (float) that the content_val must exceed in order to diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 6cf757fa..268233c3 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -104,7 +104,7 @@ class _FrameData: def __init__( self, threshold: float = 27.0, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: ty.Optional[int] = None, @@ -113,8 +113,9 @@ def __init__( """ Arguments: threshold: Threshold the average change in pixel intensity must exceed to trigger a cut. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). weights: Weight to place on each component when calculating frame score (`content_val` in a statsfile, the value `threshold` is compared against). luma_only: If True, only considers changes in the luminance channel of the video. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 484f49d5..af0994d1 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -41,8 +41,9 @@ class HashDetector(SceneDetector): size: Size of square of low frequency data to use for the DCT lowpass: How much high frequency information to filter from the DCT. A value of 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). """ def __init__( @@ -50,7 +51,7 @@ def __init__( threshold: float = 0.395, size: int = 16, lowpass: int = 2, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, ): super(HashDetector, self).__init__() self._threshold = threshold diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 812c5852..8502e1e5 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -30,7 +30,12 @@ class HistogramDetector(SceneDetector): METRIC_KEYS = ["hist_diff"] - def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): + def __init__( + self, + threshold: float = 0.05, + bins: int = 256, + min_scene_len: ty.Union[int, float, str] = 15, + ): """ Arguments: threshold: maximum relative difference between 0.0 and 1.0 that the histograms can @@ -38,8 +43,9 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int YUV, and normalized based on the number of bins. Higher dicfferences imply greater change in content, so larger threshold values are less sensitive to cuts. bins: Number of bins to use for the histogram. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). """ super().__init__() # Internally, threshold represents the correlation between two histograms and has values diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 8d28cd62..edb63024 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -48,7 +48,7 @@ class Method(Enum): def __init__( self, threshold: float = 12, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, fade_bias: float = 0.0, add_final_scene: bool = False, method: Method = Method.FLOOR, @@ -58,8 +58,9 @@ def __init__( Arguments: threshold: 8-bit intensity value that each pixel value (R, G, and B) must be <= to in order to trigger a fade in/out. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). fade_bias: Float between -1.0 and +1.0 representing the percentage of timecode skew for the start of a scene (-1.0 causing a cut at the fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 752749cd..c559938e 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -135,7 +135,7 @@ def __init__( model_path: ty.Union[str, Path] = "tests/resources/transnetv2.onnx", onnx_providers: ty.Union[ty.List[str], None] = None, threshold: float = 0.5, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): super().__init__() diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index d5cf00de..842e1460 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -34,6 +34,41 @@ logger = logging.getLogger("pyscenedetect") +def _generate_timecode_list( + scene_list: SceneList, + num_images: int, + frame_margin: ty.Union[int, float, str], +) -> ty.List[ty.List[FrameTimecode]]: + """Generate per-scene image timecodes using PTS-accurate seconds-based timing. + + `frame_margin` accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``). + """ + framerate = scene_list[0][0].framerate + margin_secs = FrameTimecode(timecode=frame_margin, fps=framerate).seconds + result = [] + for start, end in scene_list: + duration_secs = (end - start).seconds + if duration_secs <= 0: + result.append([start] * num_images) + continue + segment_secs = duration_secs / num_images + timecodes = [] + for j in range(num_images): + seg_start = start.seconds + j * segment_secs + seg_end = start.seconds + (j + 1) * segment_secs + if num_images == 1: + t = start.seconds + duration_secs / 2.0 + elif j == 0: + t = min(seg_start + margin_secs, seg_end) + elif j == num_images - 1: + t = max(seg_end - margin_secs, seg_start) + else: + t = (seg_start + seg_end) / 2.0 + timecodes.append(FrameTimecode(t, fps=framerate)) + result.append(timecodes) + return result + + def _scale_image( image: np.ndarray, aspect_ratio: float, @@ -69,7 +104,7 @@ class _ImageExtractor: def __init__( self, num_images: int = 3, - frame_margin: int = 1, + frame_margin: ty.Union[int, float, str] = 1, image_extension: str = "jpg", imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", @@ -85,10 +120,10 @@ def __init__( Arguments: num_images: Number of images to generate for each scene. Minimum is 1. - frame_margin: Number of frames to pad each scene around the beginning - and end (e.g. moves the first/last image into the scene by N frames). - Can set to 0, but will result in some video files failing to extract - the very last frame. + frame_margin: Padding around the beginning and end of each scene used when + selecting which frames to extract. Accepts an int (frames), float (seconds), + or str (e.g. ``"0.1s"``, ``"00:00:00.100"``). Can be 0, but some video files + may then fail to extract the very last frame. image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. @@ -296,31 +331,7 @@ def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.List[Frame Uses PTS-accurate seconds-based timing so results are correct for both CFR and VFR video. """ - framerate = scene_list[0][0].framerate - # Convert frame_margin to seconds using the nominal framerate. - margin_secs = self._frame_margin / framerate - result = [] - for start, end in scene_list: - duration_secs = (end - start).seconds - if duration_secs <= 0: - result.append([start] * self._num_images) - continue - segment_secs = duration_secs / self._num_images - timecodes = [] - for j in range(self._num_images): - seg_start = start.seconds + j * segment_secs - seg_end = start.seconds + (j + 1) * segment_secs - if self._num_images == 1: - t = start.seconds + duration_secs / 2.0 - elif j == 0: - t = min(seg_start + margin_secs, seg_end) - elif j == self._num_images - 1: - t = max(seg_end - margin_secs, seg_start) - else: - t = (seg_start + seg_end) / 2.0 - timecodes.append(FrameTimecode(t, fps=framerate)) - result.append(timecodes) - return result + return _generate_timecode_list(scene_list, self._num_images, self._frame_margin) def resize_image( self, @@ -336,7 +347,7 @@ def save_images( scene_list: SceneList, video: VideoStream, num_images: int = 3, - frame_margin: int = 1, + frame_margin: ty.Union[int, float, str] = 1, image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", @@ -357,10 +368,10 @@ def save_images( video: A VideoStream object corresponding to the scene list. Note that the video will be closed/re-opened and seeked through. num_images: Number of images to generate for each scene. Minimum is 1. - frame_margin: Number of frames to pad each scene around the beginning - and end (e.g. moves the first/last image into the scene by N frames). - Can set to 0, but will result in some video files failing to extract - the very last frame. + frame_margin: Padding around the beginning and end of each scene used when + selecting which frames to extract. Accepts an int (frames), float (seconds), + or str (e.g. ``"0.1s"``, ``"00:00:00.100"``). Can be 0, but some video files + may then fail to extract the very last frame. image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. @@ -398,8 +409,10 @@ def save_images( if not scene_list: return {} - if num_images <= 0 or frame_margin < 0: - raise ValueError() + if num_images <= 0: + raise ValueError("num_images must be greater than 0") + if isinstance(frame_margin, (int, float)) and frame_margin < 0: + raise ValueError("frame_margin must be non-negative") # TODO: Validate that encoder_param is within the proper range. # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. @@ -440,45 +453,7 @@ def save_images( image_num_format = "%0" image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - framerate = scene_list[0][0]._rate - - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - timecode_list = [ - [ - FrameTimecode(int(f), fps=framerate) - for f in ( - # middle frames - a[len(a) // 2] - if (0 < j < num_images - 1) or num_images == 1 - # first frame - else min(a[0] + frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, num_images)) - ) - ] - for i, r in enumerate( - [ - # pad ranges to number of images - r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.frame_num, - start.frame_num - + max( - 1, # guard against zero length scenes - end.frame_num - start.frame_num, - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ] - ) - ] + timecode_list = _generate_timecode_list(scene_list, num_images, frame_margin) image_filenames = {i: [] for i in range(len(timecode_list))} aspect_ratio = video.aspect_ratio diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 0e5f4214..8db13213 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -224,3 +224,25 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len + + +@pytest.mark.parametrize("detector_type", FAST_CUT_DETECTORS) +@pytest.mark.parametrize( + "min_scene_len", + # 30 frames at goldeneye.mp4's 24000/1001 (~23.976) fps is ~1.2513s. All four forms should + # produce identical cut lists, demonstrating that detectors accept temporal as well as + # frame-count values. + [30, 1.25, "1.25s", "00:00:01.250"], +) +def test_min_scene_len_accepts_time_values(detector_type, min_scene_len): + """Detectors accept min_scene_len as int (frames), float (seconds), or str (timecode).""" + test_case = TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=detector_type(min_scene_len=min_scene_len), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365], + ) + scene_list = test_case.detect() + start_frames = [timecode.frame_num for timecode, _ in scene_list] + assert start_frames == test_case.scene_boundaries diff --git a/tests/test_output.py b/tests/test_output.py index db3f2307..3936f5e8 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -161,6 +161,38 @@ def test_save_images_singlethreaded(test_video_file, tmp_path: Path): assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) +@pytest.mark.parametrize("frame_margin", [1, 0.1, "0.1s", "00:00:00.100"]) +def test_save_images_frame_margin_accepts_time_values( + test_video_file, tmp_path: Path, frame_margin +): + """save_images() should accept frame counts (int), seconds (float), and timecode strings.""" + video = VideoStreamCv2(test_video_file) + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300)] + ] + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template="scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER", + frame_margin=frame_margin, + ) + for paths in image_filenames.values(): + for path in paths: + assert tmp_path.joinpath(path).exists() + + +def test_save_images_rejects_negative_margin(test_video_file, tmp_path: Path): + video = VideoStreamCv2(test_video_file) + scene_list = [(FrameTimecode(0, video.frame_rate), FrameTimecode(10, video.frame_rate))] + with pytest.raises(ValueError): + save_images(scene_list=scene_list, output_dir=tmp_path, video=video, frame_margin=-1) + + # TODO: Test other functionality against zero width scenes. def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 535e2d5a..3f4fa857 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -677,6 +677,7 @@ Although there have been minimal changes to most API examples, there are several - [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters - [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command @@ -702,6 +703,7 @@ Although there have been minimal changes to most API examples, there are several * Remove `SceneDetector.is_processing_required()` method * Remove `SceneDetector.stats_manager_required` property, no longer required * Remove deprecated `SparseSceneDetector` interface + * Detector `min_scene_len` and `save_images()` `frame_margin` arguments now accept seconds (`float`) and timecode strings (e.g. `"0.6s"`, `"00:00:00.600"`) in addition to a frame count (`int`); these are evaluated using the source video's timing for correct behavior on VFR videos [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) **Module Reorganization:** From 129ec328cd71d803f78a0836e4d52677fdf46556 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 17:22:26 -0400 Subject: [PATCH 049/163] [build] Use package managers for ffmpeg install (#541) * [build] Use package managers for ffmpeg install * [build] Enable ffmpeg tests on ARM --- .github/actions/setup-ffmpeg/action.yml | 89 ++++++++++++++++--------- .github/workflows/build.yml | 3 - 2 files changed, 58 insertions(+), 34 deletions(-) diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml index 2bfb4f7e..fbf89347 100644 --- a/.github/actions/setup-ffmpeg/action.yml +++ b/.github/actions/setup-ffmpeg/action.yml @@ -1,41 +1,68 @@ name: 'Setup FFmpeg' +description: 'Ensure ffmpeg is available on the runner, using OS package managers as a fallback.' inputs: github-token: - required: true + description: 'Unused; kept for backward compatibility with existing callers.' + required: false + default: '' runs: using: 'composite' steps: - - name: Setup FFmpeg (latest) - id: latest - continue-on-error: true - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} + - name: Check for preinstalled ffmpeg + id: check + shell: bash + run: | + if command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg already available at: $(command -v ffmpeg)" + ffmpeg -version | head -n 1 + echo "installed=true" >> "$GITHUB_OUTPUT" + else + echo "ffmpeg not found on PATH; will install via package manager." + echo "installed=false" >> "$GITHUB_OUTPUT" + fi - - name: Setup FFmpeg (7.0.0) - if: ${{ steps.latest.outcome == 'failure' }} - id: v7-0-0 - continue-on-error: true - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "7.0.0" + - name: Install ffmpeg (Linux) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Linux' }} + shell: bash + run: | + for attempt in 1 2 3; do + echo "apt-get attempt $attempt" + if sudo apt-get update && sudo apt-get install -y ffmpeg; then + exit 0 + fi + sleep 10 + done + echo "Failed to install ffmpeg via apt-get after 3 attempts" >&2 + exit 1 - - name: Setup FFmpeg (6.1.1) - if: ${{ steps.v7-0-0.outcome == 'failure' }} - id: v6-1-1 - continue-on-error: true - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "6.1.1" + - name: Install ffmpeg (macOS) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'macOS' }} + shell: bash + run: | + for attempt in 1 2 3; do + echo "brew attempt $attempt" + if brew install ffmpeg; then + exit 0 + fi + sleep 10 + done + echo "Failed to install ffmpeg via brew after 3 attempts" >&2 + exit 1 - # The oldest version we allow falling back to must not have `continue-on-error: true` - - name: Setup FFmpeg (6.1.0) - if: ${{ steps.v6-1-1.outcome == 'failure' }} - id: v6-1-0 - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "6.1.0" + - name: Install ffmpeg (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + shell: pwsh + run: | + for ($attempt = 1; $attempt -le 3; $attempt++) { + Write-Host "choco attempt $attempt" + choco install ffmpeg -y --no-progress + if ($LASTEXITCODE -eq 0) { exit 0 } + Start-Sleep -Seconds 10 + } + Write-Error "Failed to install ffmpeg via choco after 3 attempts" + exit 1 + + - name: Verify ffmpeg + shell: bash + run: ffmpeg -version | head -n 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 957dfce8..71ecf7ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,9 +36,6 @@ jobs: - uses: actions/checkout@v4 - name: Setup FFmpeg - # TODO: This action currently does not work for non-x64 builders (e.g. macos-14): - # https://github.com/federicocarboni/setup-ffmpeg/issues/21 - if: ${{ runner.arch == 'X64' }} uses: ./.github/actions/setup-ffmpeg with: github-token: ${{ secrets.GITHUB_TOKEN }} From e67b0a356adb2f61e47ee540e3c63654ce41cfcf Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 22:56:28 -0400 Subject: [PATCH 050/163] [bugfix] Retry transient `OSError` exceptions with the MoviePy backend (#542) * [tests] Add deflake for #496 * [backends] Add retries for OSError in VideoStreamMoviePy #496 --- scenedetect/backends/moviepy.py | 40 ++++++++++++++++++++++++++++++--- tests/test_video_stream.py | 23 +++++++++---------- website/pages/changelog.md | 1 + 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 6624cdbe..8701c47d 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -16,6 +16,7 @@ image sequences or AviSynth scripts are supported as inputs. """ +import time import typing as ty from fractions import Fraction from logging import getLogger @@ -31,6 +32,33 @@ logger = getLogger("pyscenedetect") +# MoviePy spawns ffmpeg as a subprocess and reads frame bytes over stdout. Under +# load the parent can read before the child has flushed its first write, which +# surfaces as OSError (see #496). A short retry clears nearly all such flakes. +_FFMPEG_RETRY_COUNT = 2 +_FFMPEG_RETRY_BACKOFF_SECS = 0.5 + + +def _retry_on_oserror(op_name: str, fn: ty.Callable): + """Run ``fn``, retrying up to ``_FFMPEG_RETRY_COUNT`` times on ``OSError``.""" + last_exc: ty.Optional[OSError] = None + for attempt in range(_FFMPEG_RETRY_COUNT + 1): + try: + return fn() + except OSError as ex: + last_exc = ex + if attempt < _FFMPEG_RETRY_COUNT: + logger.warning( + "ffmpeg %s failed (attempt %d/%d), retrying: %s", + op_name, + attempt + 1, + _FFMPEG_RETRY_COUNT + 1, + ex, + ) + time.sleep(_FFMPEG_RETRY_BACKOFF_SECS) + assert last_exc is not None + raise last_exc + class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" @@ -63,7 +91,9 @@ def __init__( # cases return IOErrors (e.g. could not read duration/video resolution). These # should be mapped to specific errors, e.g. write a function to map MoviePy # exceptions to a new set of equivalents. - self._reader = FFMPEG_VideoReader(path, print_infos=print_infos) + self._reader = _retry_on_oserror( + "open", lambda: FFMPEG_VideoReader(path, print_infos=print_infos) + ) # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame # will always be the current frame, and self._reader.lastread will be the next. @@ -184,7 +214,9 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: - self._last_frame = self._reader.get_frame(target.seconds) + self._last_frame = _retry_on_oserror( + "seek", lambda: self._reader.get_frame(target.seconds) + ) if hasattr(self._reader, "last_read") and target >= self.duration: raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") self._frame_number = min( @@ -212,7 +244,9 @@ def reset(self, print_infos=False): self._last_frame_rgb = None self._frame_number = 0 self._eof = False - self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) + self._reader = _retry_on_oserror( + "reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) + ) def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: if not hasattr(self._reader, "lastread") or self._eof: diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 922be83d..115c16e4 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -121,20 +121,17 @@ def get_test_video_params() -> ty.List[VideoParameters]: ] +_VS_TYPES: list = [vs for vs in (VideoStreamCv2, VideoStreamAv) if vs is not None] +if VideoStreamMoviePy is not None: + _VS_TYPES.append( + pytest.param( + VideoStreamMoviePy, + marks=pytest.mark.flaky(reruns=3, reruns_delay=2, only_rerun=["OSError"]), + ) + ) + pytestmark = [ - pytest.mark.parametrize( - "vs_type", - list( - filter( - lambda x: x is not None, - [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - ], - ) - ), - ), + pytest.mark.parametrize("vs_type", _VS_TYPES), pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), ] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 3f4fa857..e7d61ed0 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -679,6 +679,7 @@ Although there have been minimal changes to most API examples, there are several - [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision +- [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496) - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command ### API Changes From c0d7c3afbdc8dda108c7e4c93187f447025f82a8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 22 Apr 2026 22:46:48 -0400 Subject: [PATCH 051/163] [lint] Fix E731 warnings, add W rules, and un-exclude docs/ --- docs/generate_cli_docs.py | 27 ++++++++++++++++----------- pyproject.toml | 6 +----- scenedetect/output/video.py | 31 +++++++++++++++++-------------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index a1ba2eca..6c5bf13c 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -20,10 +20,10 @@ currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parentdir = os.path.dirname(currentdir) sys.path.insert(0, parentdir) -# Third-party imports -import click +# Third-party imports (must follow sys.path mutation above). +import click # noqa: E402 -from scenedetect._cli import scenedetect +from scenedetect._cli import scenedetect # noqa: E402 StrGenerator = ty.Generator[str, None, None] @@ -80,7 +80,7 @@ def patch_help(s: str, commands: ty.List[str]) -> str: for command in [command for command in commands if command not in INFO_COMMANDS]: - def add_link(_match: re.Match) -> str: + def add_link(_match: re.Match, command: str = command) -> str: return ":ref:`%s `" % (command, command) s = re.sub("``%s``(?!\\n)" % command, add_link, s) @@ -116,7 +116,7 @@ def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: def _add_backquotes(s: re.Match) -> str: to_add: str = s.string[s.start() : s.end()] - flag = re.search("-+[\w-]+[^\.\=\s\/]*", to_add) + flag = re.search(r"-+[\w-]+[^\.\=\s\/]*", to_add) if flag is not None and flag.string[flag.start() : flag.end()] in refs: # add cross reference cross_ref = flag.string[flag.start() : flag.end()] @@ -129,7 +129,7 @@ def _add_backquotes(s: re.Match) -> str: def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: - default = re.search("\[default: .*\]", s) + default = re.search(r"\[default: .*\]", s) if default is not None: span = default.span() assert span[1] == len(s) @@ -145,11 +145,11 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. # -c/--command - s = re.sub("-\w/--\w[\w-]*", transform, s) + s = re.sub(r"-\w/--\w[\w-]*", transform, s) # --arg=value, --arg=1.2.3, --arg=1,2,3 - s = re.sub('-+[\w-]+=[^"\s\)]+(? ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) - commands = list(filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands)) + commands = list( + filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands) + ) # ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ generate_title("``scenedetect`` 🎬 Command", level=0), @@ -268,7 +270,10 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: def main(): help, commands = create_help() help = patch_help(help, commands) - help = ".. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified.\n" + help + help = ( + ".. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified.\n" + + help + ) with open("docs/cli.rst", "wb") as f: f.write(help.encode()) diff --git a/pyproject.toml b/pyproject.toml index 8186012b..9ec220e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,6 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.ruff] -exclude = [ - "docs" -] line-length = 100 indent-width = 4 @@ -31,6 +28,7 @@ select = [ "B", # pycodestyle "E", + "W", # Pyflakes "F", # isort @@ -46,8 +44,6 @@ ignore = [ "F401", # TODO: Line too long "E501", - # TODO: Do not assign a `lambda` expression, use a `def` - "E731", ] fixable = ["ALL"] unfixable = [] diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 07b01c1b..a503db6b 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -129,20 +129,23 @@ def default_formatter(template: str) -> PathFormatter: `$START_PTS`, `$END_PTS` (presentation timestamp in milliseconds, accurate for VFR video) """ MIN_DIGITS = 3 - format_scene_number: PathFormatter = lambda video, scene: ( - ("%0" + str(max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1)) + "d") - % (scene.index + 1) - ) - formatter: PathFormatter = lambda video, scene: Template(template).safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=format_scene_number(video, scene), - START_TIME=str(scene.start.get_timecode().replace(":", ";")), - END_TIME=str(scene.end.get_timecode().replace(":", ";")), - START_FRAME=str(scene.start.frame_num), - END_FRAME=str(scene.end.frame_num), - START_PTS=str(round(scene.start.seconds * 1000)), - END_PTS=str(round(scene.end.seconds * 1000)), - ) + + def format_scene_number(video: VideoMetadata, scene: SceneMetadata) -> str: + width = max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1) + return ("%0" + str(width) + "d") % (scene.index + 1) + + def formatter(video: VideoMetadata, scene: SceneMetadata) -> str: + return Template(template).safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=format_scene_number(video, scene), + START_TIME=str(scene.start.get_timecode().replace(":", ";")), + END_TIME=str(scene.end.get_timecode().replace(":", ";")), + START_FRAME=str(scene.start.frame_num), + END_FRAME=str(scene.end.frame_num), + START_PTS=str(round(scene.start.seconds * 1000)), + END_PTS=str(round(scene.end.seconds * 1000)), + ) + return formatter From 7fce8414ae1cfb921cb7086c49c7e9e0648696cb Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Wed, 22 Apr 2026 22:49:58 -0400 Subject: [PATCH 052/163] Add New Output Methods to Python API + Finalize FCPX Support (#543) * [cli] Finalize `save-xml` for release Tested FCPX and FCP7 formats on DaVinci Resolve. * [api] Add EDL, FCP7/X, and OTIO formats to scenedetect.output Rename save-xml -> save-fcp. * [docs] Regenerate CLI docs --- docs/api/migration_guide.rst | 2 +- docs/api/output.rst | 8 + docs/cli.rst | 40 +++- scenedetect.cfg | 14 ++ scenedetect/_cli/__init__.py | 28 +-- scenedetect/_cli/commands.py | 348 +++++------------------------ scenedetect/_cli/config.py | 14 +- scenedetect/output/__init__.py | 389 +++++++++++++++++++++++++++++++++ tests/test_cli.py | 109 +++++++++ tests/test_output.py | 214 ++++++++++++++++++ tests/test_vfr.py | 26 +++ website/pages/changelog.md | 3 +- 12 files changed, 874 insertions(+), 321 deletions(-) diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index a3ae9fa7..e361a3a6 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -203,4 +203,4 @@ 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. +- New ``save-fcp`` command for exporting scenes in Final Cut Pro XML format. diff --git a/docs/api/output.rst b/docs/api/output.rst index 480e4371..8b594b9b 100644 --- a/docs/api/output.rst +++ b/docs/api/output.rst @@ -20,6 +20,14 @@ Ouptut .. autofunction:: scenedetect.output.write_scene_list +.. autofunction:: scenedetect.output.write_scene_list_edl + +.. autofunction:: scenedetect.output.write_scene_list_fcpx + +.. autofunction:: scenedetect.output.write_scene_list_fcp7 + +.. autofunction:: scenedetect.output.write_scene_list_otio + .. autoclass:: scenedetect.output.SceneMetadata .. autoclass:: scenedetect.output.VideoMetadata diff --git a/docs/cli.rst b/docs/cli.rst index 6df3d9f7..cca81f67 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -178,8 +178,6 @@ Options Default: ``15.0`` - - .. option:: -f VAL, --frame-window VAL Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean. @@ -552,6 +550,38 @@ Options Output directory to save EDL file to. Overrides global option :option:`-o/--output `. +.. _command-save-fcp: + +.. program:: scenedetect save-fcp + + +``save-fcp`` +======================================================================== + +Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX). + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.xml`` + +.. option:: --format TYPE + + Format to export. TYPE must be one of: fcpx, fcp7. + + Default: ``FcpFormat.FCPX`` + +.. option:: -o DIR, --output DIR + + Output directory to save XML file to. Overrides global option :option:`-o/--output `. + + .. _command-save-html: .. program:: scenedetect save-html @@ -658,11 +688,11 @@ Options Default: ``3`` -.. option:: -m N, --frame-margin N +.. option:: -m DURATION, --frame-margin DURATION - Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries. + Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100). - Default: ``3`` + Default: ``1`` .. option:: -s S, --scale S diff --git a/scenedetect.cfg b/scenedetect.cfg index f0901cc0..a18189e5 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -345,6 +345,20 @@ #disable-shift = no +[save-fcp] + +# Filename format of XML file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.xml + +# Format of the XML file. Must be one of: +# - fcpx: Final Cut Pro X (FCPXML, default) +# - fcp7: Final Cut Pro 7 (xmeml) +#format = fcpx + +# Folder to output XML file to. Overrides [global] output option. +#output = /usr/tmp/images + + # # BACKEND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 88dc9654..59479bcd 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1614,27 +1614,27 @@ def save_qp_command( ctx.add_command(cli_commands.save_qp, save_qp_args) -SAVE_XML_HELP = """[IN DEVELOPMENT] Save cuts in XML format.""" +SAVE_FCP_HELP = """Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX).""" -@click.command("save-xml", cls=Command, help=SAVE_XML_HELP, hidden=True) +@click.command("save-fcp", cls=Command, help=SAVE_FCP_HELP) @click.option( "--filename", "-f", metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-xml", "filename")), + help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-fcp", "filename")), ) @click.option( "--format", metavar="TYPE", - type=click.Choice(CHOICE_MAP["save-xml"]["format"], False), + type=click.Choice(CHOICE_MAP["save-fcp"]["format"], False), default=None, help="Format to export. TYPE must be one of: %s.%s" % ( - ", ".join(CHOICE_MAP["save-xml"]["format"]), - USER_CONFIG.get_help_string("save-xml", "format"), + ", ".join(CHOICE_MAP["save-fcp"]["format"]), + USER_CONFIG.get_help_string("save-fcp", "format"), ), ) @click.option( @@ -1643,10 +1643,10 @@ def save_qp_command( metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), help="Output directory to save XML file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-xml", "output", show_default=False)), + % (USER_CONFIG.get_help_string("save-fcp", "output", show_default=False)), ) @click.pass_context -def save_xml_command( +def save_fcp_command( ctx: click.Context, filename: ty.Optional[ty.AnyStr], format: ty.Optional[ty.AnyStr], @@ -1655,12 +1655,12 @@ def save_xml_command( ctx = ctx.obj assert isinstance(ctx, CliContext) - save_xml_args = { - "filename": ctx.config.get_value("save-xml", "filename", filename), - "format": ctx.config.get_value("save-xml", "format", format), - "output": ctx.config.get_value("save-xml", "output", output), + save_fcp_args = { + "filename": ctx.config.get_value("save-fcp", "filename", filename), + "format": ctx.config.get_value("save-fcp", "format", format), + "output": ctx.config.get_value("save-fcp", "output", output), } - ctx.add_command(cli_commands.save_xml, save_xml_args) + ctx.add_command(cli_commands.save_fcp, save_fcp_args) SAVE_OTIO_HELP = """Save cuts as an OTIO timeline. @@ -1757,7 +1757,7 @@ def save_otio_command( scenedetect.add_command(save_html_command) scenedetect.add_command(save_images_command) scenedetect.add_command(save_qp_command) -scenedetect.add_command(save_xml_command) +scenedetect.add_command(save_fcp_command) scenedetect.add_command(save_otio_command) scenedetect.add_command(split_video_command) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 0003f30e..730ff277 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -15,27 +15,23 @@ current command-line context, as well as the processing result (scenes and cuts). """ -import json import logging -import os.path import typing as ty import webbrowser -from datetime import datetime -from pathlib import Path from string import Template -from xml.dom import minidom -from xml.etree import ElementTree -import scenedetect -from scenedetect._cli.config import XmlFormat +from scenedetect._cli.config import FcpFormat from scenedetect._cli.context import CliContext -from scenedetect.common import FrameTimecode from scenedetect.output import save_images as save_images_impl from scenedetect.output import ( split_video_ffmpeg, split_video_mkvmerge, write_scene_list, + write_scene_list_edl, + write_scene_list_fcp7, + write_scene_list_fcpx, write_scene_list_html, + write_scene_list_otio, ) from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import ( @@ -272,224 +268,63 @@ def save_edl( reel: str, ): """Handles the `save-edl` command. Outputs in CMX 3600 format.""" - # We only use scene information. - del cuts - - # Converts FrameTimecode to HH:MM:SS:FF - # TODO: This should be part of the FrameTimecode object itself. - def get_edl_timecode(timecode: FrameTimecode): - total_seconds = timecode.seconds - hours = int(total_seconds // 3600) - minutes = int((total_seconds % 3600) // 60) - seconds = int(total_seconds % 60) - frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) - return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" - - edl_content = [] - - title = Template(title).safe_substitute(VIDEO_NAME=context.video_stream.name) - edl_content.append(f"TITLE: {title}") - edl_content.append("FCM: NON-DROP FRAME") - edl_content.append("") - - # Add each shot as an edit entry - for i, (start, end) in enumerate(scenes): - in_tc = get_edl_timecode(start) - out_tc = get_edl_timecode(end) # Correct for presentation time - # Format the edit entry according to CMX 3600 format - event_line = f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}" - edl_content.append(event_line) - - edl_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), - output, - ) - logger.info(f"Writing scenes in EDL format to {edl_path}") - with open(edl_path, "w") as f: - f.write(f"* CREATED WITH PYSCENEDETECT {scenedetect.__version__}\n") - f.write("\n".join(edl_content)) - f.write("\n") - - -def _save_xml_fcpx( - context: CliContext, - scenes: SceneList, - filename: str, - output: str, -): - """Saves scenes in Final Cut Pro X XML format.""" - ASSET_ID = "asset1" - FORMAT_ID = "format1" - # TODO: Need to handle other video formats! - VIDEO_FORMAT_TODO_HANDLE_OTHERS = "FFVideoFormat1080p24" - - root = ElementTree.Element("fcpxml", version="1.9") - resources = ElementTree.SubElement(root, "resources") - ElementTree.SubElement(resources, "format", id="format1", name=VIDEO_FORMAT_TODO_HANDLE_OTHERS) - + del cuts # We only use scene information. video_name = context.video_stream.name - - # TODO: We should calculate duration from the scene list. - duration = context.video_stream.duration - duration = str(duration.seconds) + "s" # TODO: Is float okay here? - path = Path(context.video_stream.path).absolute() - ElementTree.SubElement( - resources, - "asset", - id=ASSET_ID, - name=video_name, - src=str(path), - duration=duration, - hasVideo="1", - hasAudio="1", # TODO: Handle case of no audio. - format=FORMAT_ID, - ) - - library = ElementTree.SubElement(root, "library") - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - event = ElementTree.SubElement(library, "event", name=f"Shot Detection {now}") - project = ElementTree.SubElement( - event, "project", name=video_name - ) # TODO: Allow customizing project name. - sequence = ElementTree.SubElement(project, "sequence", format=FORMAT_ID, duration=duration) - spine = ElementTree.SubElement(sequence, "spine") - - for i, (start, end) in enumerate(scenes): - start_seconds = start.seconds - duration_seconds = (end - start).seconds - clip = ElementTree.SubElement( - spine, - "clip", - name=f"Shot {i + 1}", - duration=f"{duration_seconds:.3f}s", - start=f"{start_seconds:.3f}s", - offset=f"{start_seconds:.3f}s", - ) - ElementTree.SubElement( - clip, - "asset-clip", - ref=ASSET_ID, - duration=f"{duration_seconds:.3f}s", - start=f"{start_seconds:.3f}s", - offset="0s", - name=f"Shot {i + 1}", - ) - - pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( - indent=" " - ) - xml_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + edl_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), output, ) - logger.info(f"Writing scenes in FCPX format to {xml_path}") - with open(xml_path, "w") as f: - f.write(pretty_xml) - - -def _save_xml_fcp( - context: CliContext, - scenes: SceneList, - filename: str, - output: str, -): - """Saves scenes in Final Cut Pro 7 XML format.""" - assert scenes - root = ElementTree.Element("xmeml", version="5") - project = ElementTree.SubElement(root, "project") - ElementTree.SubElement(project, "name").text = context.video_stream.name - 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 = str(round(duration.seconds * fps)) - - rate = ElementTree.SubElement(sequence, "rate") - 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(round(fps)) - ElementTree.SubElement(tc_rate, "ntsc").text = ntsc - ElementTree.SubElement(timecode, "frame").text = "0" - ElementTree.SubElement(timecode, "displayformat").text = "NDF" - - media = ElementTree.SubElement(sequence, "media") - video = ElementTree.SubElement(media, "video") - format = ElementTree.SubElement(video, "format") - ElementTree.SubElement(format, "samplecharacteristics") - track = ElementTree.SubElement(video, "track") - - # Add clips for each shot boundary - for i, (start, end) in enumerate(scenes): - clip = ElementTree.SubElement(track, "clipitem") - ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" - ElementTree.SubElement(clip, "enabled").text = "TRUE" - ElementTree.SubElement(clip, "rate").append( - ElementTree.fromstring(f"{round(fps)}") - ) - # Frame numbers relative to the declared 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 - path = Path(context.video_stream.path).absolute() - # TODO: Can we just use path.as_uri() here? - # On Windows this should be: file://localhost/C:/Users/... according to the samples provided - # from https://github.com/Breakthrough/PySceneDetect/issues/156#issuecomment-1076213412. - ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}" - - media_ref = ElementTree.SubElement(file_ref, "media") - video_ref = ElementTree.SubElement(media_ref, "video") - ElementTree.SubElement(video_ref, "samplecharacteristics") - link = ElementTree.SubElement(clip, "link") - ElementTree.SubElement(link, "linkclipref").text = f"file{i + 1}" - ElementTree.SubElement(link, "mediatype").text = "video" - - pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( - indent=" " - ) - xml_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), - output, + write_scene_list_edl( + output_path=edl_path, + scene_list=scenes, + title=Template(title).safe_substitute(VIDEO_NAME=video_name), + reel=reel, ) - logger.info(f"Writing scenes in FCP format to {xml_path}") - with open(xml_path, "w") as f: - f.write(pretty_xml) -def save_xml( +def save_fcp( context: CliContext, scenes: SceneList, cuts: CutList, filename: str, - format: XmlFormat, + format: FcpFormat, output: str, ): - """Handles the `save-xml` command.""" - # We only use scene information. - del cuts - + """Handles the `save-fcp` command.""" + del cuts # We only use scene information. if not scenes: return - if format == XmlFormat.FCPX: - _save_xml_fcpx(context, scenes, filename, output) - elif format == XmlFormat.FCP: - _save_xml_fcp(context, scenes, filename, output) + video_stream = context.video_stream + video_name = str(video_stream.name) + video_path = str(video_stream.path) + xml_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), + output, + ) + if format == FcpFormat.FCPX: + write_scene_list_fcpx( + output_path=xml_path, + scene_list=scenes, + video_path=video_path, + frame_rate=video_stream.frame_rate, + frame_size=video_stream.frame_size, + video_name=video_name, + ) + elif format == FcpFormat.FCP7: + write_scene_list_fcp7( + output_path=xml_path, + scene_list=scenes, + video_path=video_path, + frame_rate=video_stream.frame_rate, + frame_size=video_stream.frame_size, + video_name=video_name, + source_duration=video_stream.duration, + ) else: 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, @@ -499,92 +334,19 @@ def save_otio( name: str, audio: bool, ): - """Saves scenes in OTIO format.""" - + """Handles the `save-otio` command.""" del cuts # We only use scene information - - 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 = 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. - track_list = {"Video 1": "Video"} - if audio: - track_list["Audio 1"] = "Audio" - - otio = { - "OTIO_SCHEMA": "Timeline.1", - "name": Template(name).safe_substitute(VIDEO_NAME=video_name), - "global_start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": 0.0, - }, - "tracks": { - "OTIO_SCHEMA": "Stack.1", - "enabled": True, - "children": [ - { - "OTIO_SCHEMA": "Track.1", - "name": track_name, - "enabled": True, - "children": [ - { - "OTIO_SCHEMA": "Clip.2", - "name": video_base_name, - "source_range": { - "OTIO_SCHEMA": "TimeRange.1", - "duration": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": round((end - start).seconds * frame_rate, 6), - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": round(start.seconds * frame_rate, 6), - }, - }, - "enabled": True, - "media_references": { - "DEFAULT_MEDIA": { - "OTIO_SCHEMA": "ExternalReference.1", - "name": video_base_name, - "available_range": { - "OTIO_SCHEMA": "TimeRange.1", - "duration": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": 1980.0, - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": 0.0, - }, - }, - "available_image_bounds": None, - "target_url": video_path, - } - }, - "active_media_reference_key": "DEFAULT_MEDIA", - } - for (start, end) in scenes - ], - "kind": track_type, - } - for (track_name, track_type) in track_list.items() - ], - }, - } - + video_stream = context.video_stream + video_name = str(video_stream.name) otio_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + Template(filename).safe_substitute(VIDEO_NAME=video_name), output, ) - logger.info(f"Writing scenes in OTIO format to {otio_path}") - with open(otio_path, "w") as f: - json.dump(otio, f, indent=4) - f.write("\n") + write_scene_list_otio( + output_path=otio_path, + scene_list=scenes, + video_path=str(video_stream.path), + frame_rate=video_stream.frame_rate, + name=Template(name).safe_substitute(VIDEO_NAME=video_name), + audio=audio, + ) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 80fc082f..1bf8dad0 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -305,12 +305,12 @@ def format(self, timecode: FrameTimecode) -> str: raise RuntimeError("Unhandled format specifier.") -class XmlFormat(Enum): - """Format to use with the `save-xml` command.""" +class FcpFormat(Enum): + """Format to use with the `save-fcp` command.""" FCPX = 0 """Final Cut Pro X XML Format""" - FCP = 1 + FCP7 = 1 """Final Cut Pro 7 XML Format""" @@ -433,8 +433,8 @@ class XmlFormat(Enum): "filename": "$VIDEO_NAME.qp", "output": None, }, - "save-xml": { - "format": XmlFormat.FCPX, + "save-fcp": { + "format": FcpFormat.FCPX, "filename": "$VIDEO_NAME.xml", "output": None, }, @@ -480,8 +480,8 @@ class XmlFormat(Enum): "format": ["jpeg", "png", "webp"], "scale-method": [value.name.lower() for value in Interpolation], }, - "save-xml": { - "format": [value.name.lower() for value in XmlFormat], + "save-fcp": { + "format": [value.name.lower() for value in FcpFormat], }, "split-video": { "preset": [ diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 3acd48f8..b913933f 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -16,8 +16,13 @@ """ import csv +import json import logging import typing as ty +from fractions import Fraction +from pathlib import Path +from xml.dom import minidom +from xml.etree import ElementTree from scenedetect._thirdparty.simpletable import ( HTMLPage, @@ -28,6 +33,7 @@ ) from scenedetect.common import ( CutList, + FrameTimecode, SceneList, ) @@ -233,3 +239,386 @@ def write_scene_list_html( page.add_table(scene_table) page.css = css page.save(output_html_filename) + + +def _edl_timecode(timecode: FrameTimecode) -> str: + """Format `timecode` as ``HH:MM:SS:FF`` for a CMX 3600 EDL entry.""" + total_seconds = timecode.seconds + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" + + +def write_scene_list_edl( + output_path: ty.Union[str, Path], + scene_list: SceneList, + title: str = "PySceneDetect", + reel: str = "AX", +): + """Writes the given list of scenes to `output_path` in CMX 3600 EDL format. + + Arguments: + output_path: Path to write the EDL file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end. + title: Title header written as ``TITLE:`` in the EDL. + reel: Reel name used for each event. Typically 2-8 uppercase characters. + """ + output_path = Path(output_path) + lines = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""] + for i, (start, end) in enumerate(scene_list): + in_tc = _edl_timecode(start) + out_tc = _edl_timecode(end) + lines.append(f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}") + logger.info("Writing scenes in EDL format to %s", output_path) + with open(output_path, "w") as f: + # `scenedetect` is imported lazily to avoid a circular import at module load. + import scenedetect + + f.write(f"* CREATED WITH PYSCENEDETECT {scenedetect.__version__}\n") + f.write("\n".join(lines)) + f.write("\n") + + +def _rational_seconds(value: Fraction) -> str: + """Format a `Fraction` as an FCPXML rational time string. + + FCPXML expresses time as ``/s`` (or ``s`` for whole seconds). See + https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference + """ + if value.denominator == 1: + return f"{value.numerator}s" + return f"{value.numerator}/{value.denominator}s" + + +def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: + """Exact seconds for `tc` as a `Fraction`, derived from PTS × time base.""" + return Fraction(tc.pts) * tc.time_base + + +def write_scene_list_fcpx( + output_path: ty.Union[str, Path], + scene_list: SceneList, + video_path: ty.Union[str, Path], + frame_rate: Fraction, + frame_size: ty.Tuple[int, int], + video_name: ty.Optional[str] = None, +): + """Writes the given list of scenes to `output_path` in Final Cut Pro X XML format (FCPXML 1.9). + + The output follows Apple's FCPXML schema with rational-second time values and a custom + ```` derived from the source video's frame rate and resolution. See + https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference + + Arguments: + output_path: Path to write the FCPXML file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes. Must not be empty. + video_path: Path to the source video file; written into the output as a ``file://`` URI. + frame_rate: Source frame rate as a rational `Fraction` (e.g. ``Fraction(24000, 1001)``). + frame_size: Source resolution as a ``(width, height)`` tuple in pixels. + video_name: Display name used for the asset, project, and event. Defaults to the stem + of `video_path`. + """ + assert scene_list + output_path = Path(output_path) + video_path = Path(video_path) + if video_name is None: + video_name = video_path.stem + + ASSET_ID = "r2" + FORMAT_ID = "r1" + + width, height = frame_size + frame_duration = _rational_seconds(Fraction(frame_rate.denominator, frame_rate.numerator)) + src_uri = video_path.absolute().as_uri() + total_duration = _rational_seconds( + _frame_timecode_seconds(scene_list[-1][1] - scene_list[0][0]) + ) + + root = ElementTree.Element("fcpxml", version="1.9") + resources = ElementTree.SubElement(root, "resources") + # `name` is cosmetic: Apple publishes no authoritative FFVideoFormat* list, and editors key + # off frameDuration/width/height. We emit a generated name for display only. + format_name = f"FFVideoFormat{height}p{round(float(frame_rate) * 100):04d}" + ElementTree.SubElement( + resources, + "format", + id=FORMAT_ID, + name=format_name, + frameDuration=frame_duration, + width=str(width), + height=str(height), + ) + asset = ElementTree.SubElement( + resources, + "asset", + id=ASSET_ID, + name=video_name, + start="0s", + duration=total_duration, + hasVideo="1", + format=FORMAT_ID, + ) + ElementTree.SubElement(asset, "media-rep", kind="original-media", src=src_uri) + + library = ElementTree.SubElement(root, "library") + event = ElementTree.SubElement(library, "event", name=video_name) + project = ElementTree.SubElement(event, "project", name=video_name) + sequence = ElementTree.SubElement( + project, + "sequence", + format=FORMAT_ID, + duration=total_duration, + tcStart="0s", + tcFormat="NDF", + ) + spine = ElementTree.SubElement(sequence, "spine") + + for i, (start, end) in enumerate(scene_list): + scene_start = _rational_seconds(_frame_timecode_seconds(start)) + scene_duration = _rational_seconds(_frame_timecode_seconds(end - start)) + ElementTree.SubElement( + spine, + "asset-clip", + name=f"Shot {i + 1}", + ref=ASSET_ID, + offset=scene_start, + start=scene_start, + duration=scene_duration, + ) + + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( + indent=" " + ) + logger.info("Writing scenes in FCPX format to %s", output_path) + with open(output_path, "w") as f: + f.write(pretty_xml) + + +def write_scene_list_fcp7( + output_path: ty.Union[str, Path], + scene_list: SceneList, + video_path: ty.Union[str, Path], + frame_rate: Fraction, + frame_size: ty.Tuple[int, int], + video_name: ty.Optional[str] = None, + source_duration: ty.Optional[FrameTimecode] = None, +): + """Writes the given list of scenes to `output_path` in Final Cut Pro 7 XML (xmeml) format. + + See the xmeml element reference at + https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/. + ``pathurl`` is written as a valid ``file://`` URI per the xmeml spec. + + Arguments: + output_path: Path to write the xmeml file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes. Must not be empty. + video_path: Path to the source video file; written into the output as a ``file://`` URI. + frame_rate: Source frame rate as a rational `Fraction`. + frame_size: Source resolution as a ``(width, height)`` tuple in pixels. + video_name: Display name used for project and sequence. Defaults to the stem of + `video_path`. + source_duration: Total duration of the source media. Required on ```` so NLEs + (DaVinci Resolve, Premiere) can seek into the source — without it the clip plays + frozen. If None, falls back to the last scene's end time. + """ + assert scene_list + output_path = Path(output_path) + video_path = Path(video_path) + if video_name is None: + video_name = video_path.stem + + root = ElementTree.Element("xmeml", version="5") + project = ElementTree.SubElement(root, "project") + ElementTree.SubElement(project, "name").text = video_name + sequence = ElementTree.SubElement(project, "sequence") + ElementTree.SubElement(sequence, "name").text = video_name + + fps = float(frame_rate) + ntsc = "True" if frame_rate.denominator != 1 else "False" + duration = scene_list[-1][1] - scene_list[0][0] + ElementTree.SubElement(sequence, "duration").text = str(round(duration.seconds * fps)) + + rate = ElementTree.SubElement(sequence, "rate") + 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(round(fps)) + ElementTree.SubElement(tc_rate, "ntsc").text = ntsc + ElementTree.SubElement(timecode, "frame").text = "0" + ElementTree.SubElement(timecode, "displayformat").text = "NDF" + + width, height = frame_size + media = ElementTree.SubElement(sequence, "media") + video = ElementTree.SubElement(media, "video") + format = ElementTree.SubElement(video, "format") + sample_chars = ElementTree.SubElement(format, "samplecharacteristics") + ElementTree.SubElement(sample_chars, "width").text = str(width) + ElementTree.SubElement(sample_chars, "height").text = str(height) + track = ElementTree.SubElement(video, "track") + + path_uri = video_path.absolute().as_uri() + source_duration_frames = str( + round( + (source_duration.seconds if source_duration is not None else scene_list[-1][1].seconds) + * fps + ) + ) + FILE_ID = "file1" + + for i, (start, end) in enumerate(scene_list): + clip = ElementTree.SubElement(track, "clipitem") + ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" + ElementTree.SubElement(clip, "enabled").text = "TRUE" + ElementTree.SubElement(clip, "duration").text = source_duration_frames + clip_rate = ElementTree.SubElement(clip, "rate") + ElementTree.SubElement(clip_rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(clip_rate, "ntsc").text = ntsc + # Frame numbers relative to the declared 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)) + + # xmeml allows a single full `` declaration reused via `` on + # subsequent clipitems. Emit full details on the first, then self-close on the rest. + if i == 0: + file_ref = ElementTree.SubElement(clip, "file", id=FILE_ID) + ElementTree.SubElement(file_ref, "name").text = video_name + ElementTree.SubElement(file_ref, "pathurl").text = path_uri + ElementTree.SubElement(file_ref, "duration").text = source_duration_frames + file_rate = ElementTree.SubElement(file_ref, "rate") + ElementTree.SubElement(file_rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(file_rate, "ntsc").text = ntsc + media_ref = ElementTree.SubElement(file_ref, "media") + video_ref = ElementTree.SubElement(media_ref, "video") + clip_chars = ElementTree.SubElement(video_ref, "samplecharacteristics") + ElementTree.SubElement(clip_chars, "width").text = str(width) + ElementTree.SubElement(clip_chars, "height").text = str(height) + else: + ElementTree.SubElement(clip, "file", id=FILE_ID) + + link = ElementTree.SubElement(clip, "link") + ElementTree.SubElement(link, "linkclipref").text = FILE_ID + ElementTree.SubElement(link, "mediatype").text = "video" + + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( + indent=" " + ) + logger.info("Writing scenes in FCP format to %s", output_path) + with open(output_path, "w") as f: + f.write(pretty_xml) + + +# 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 write_scene_list_otio( + output_path: ty.Union[str, Path], + scene_list: SceneList, + video_path: ty.Union[str, Path], + frame_rate: Fraction, + name: ty.Optional[str] = None, + audio: bool = True, +): + """Writes the given list of scenes to `output_path` as an OTIO Timeline.1 JSON document. + + OTIO (OpenTimelineIO) timelines can be imported by many video editors. + + Arguments: + output_path: Path to write the OTIO file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes. + video_path: Path to the source video file; written into the output as an absolute path. + frame_rate: Source frame rate as a rational `Fraction`. Exported as a float, as the + current OTIO format does not support rational timings. + name: Timeline name. Defaults to the stem of `video_path`. + audio: If True (default), include an audio track alongside the video track. + """ + output_path = Path(output_path) + video_path = Path(video_path) + if name is None: + name = video_path.stem + + video_base_name = video_path.name + video_abs_path = str(video_path.absolute()) + fps = float(frame_rate) + + # List of track mapping to resource type. + # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. + track_list = {"Video 1": "Video"} + if audio: + track_list["Audio 1"] = "Audio" + + otio = { + "OTIO_SCHEMA": "Timeline.1", + "name": name, + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 0.0, + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": True, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": track_name, + "enabled": True, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": video_base_name, + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": round((end - start).seconds * fps, 6), + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": round(start.seconds * fps, 6), + }, + }, + "enabled": True, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": video_base_name, + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 1980.0, + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 0.0, + }, + }, + "available_image_bounds": None, + "target_url": video_abs_path, + } + }, + "active_media_reference_key": "DEFAULT_MEDIA", + } + for (start, end) in scene_list + ], + "kind": track_type, + } + for (track_name, track_type) in track_list.items() + ], + }, + } + + logger.info("Writing scenes in OTIO format to %s", output_path) + with open(output_path, "w") as f: + json.dump(otio, f, indent=4) + f.write("\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3f77e91f..78841978 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1160,3 +1160,112 @@ def test_cli_save_otio_no_audio(tmp_path: Path): assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace( "{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\") ) + + +def test_cli_save_fcp_fcpx(tmp_path: Path): + """Test `save-fcp --format fcpx` produces a valid FCPXML 1.9 file.""" + from xml.etree import ElementTree + + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-fcp", + ] + ) + assert exit_code == 0 + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.xml") + assert os.path.exists(output_path) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "fcpxml" + assert root.attrib["version"] == "1.9" + + # Format carries the rational frameDuration derived from the video's 24000/1001 fps. + fmt = root.find("resources/format") + assert fmt is not None + assert fmt.attrib["frameDuration"] == "1001/24000s" + assert fmt.attrib["width"] == "1280" + assert fmt.attrib["height"] == "544" + + # Asset references the source video via a file:// URI. + media_rep = root.find("resources/asset/media-rep") + assert media_rep is not None + assert media_rep.attrib["src"].startswith("file://") + assert media_rep.attrib["src"].endswith("goldeneye.mp4") + + # Spine contains one `` per scene (not wrapped in ``). + asset_clips = root.findall("library/event/project/sequence/spine/asset-clip") + assert len(asset_clips) == 2 + # All clip time attributes are rational strings ending in "s". + for clip in asset_clips: + for attr in ("offset", "start", "duration"): + assert clip.attrib[attr].endswith("s") + + +def test_cli_save_fcp_fcp7(tmp_path: Path): + """Test `save-fcp --format fcp7` produces a valid FCP7 xmeml file.""" + from xml.etree import ElementTree + + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-fcp", + "--format", + "fcp7", + ] + ) + assert exit_code == 0 + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.xml") + assert os.path.exists(output_path) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "xmeml" + assert root.attrib["version"] == "5" + + # NTSC flag is True for the 23.976 test video. + ntsc = root.find("project/sequence/rate/ntsc") + assert ntsc is not None and ntsc.text == "True" + + # samplecharacteristics carry width/height so Premiere/DaVinci can ingest. + width = root.find("project/sequence/media/video/format/samplecharacteristics/width") + height = root.find("project/sequence/media/video/format/samplecharacteristics/height") + assert width is not None and width.text == "1280" + assert height is not None and height.text == "544" + + # Two clipitems produced; first carries the full block, rest reference it by id. + clipitems = root.findall("project/sequence/media/video/track/clipitem") + assert len(clipitems) == 2 + + first_file = clipitems[0].find("file") + assert first_file is not None + assert first_file.attrib["id"] == "file1" + pathurl = first_file.find("pathurl") + assert pathurl is not None and pathurl.text is not None + assert pathurl.text.startswith("file://") + assert pathurl.text.endswith("goldeneye.mp4") + # Source duration is required for NLEs to seek into the media. + assert first_file.find("duration") is not None + + # Subsequent clipitems reference the same file id without redeclaring. + second_file = clipitems[1].find("file") + assert second_file is not None + assert second_file.attrib["id"] == "file1" + assert second_file.find("pathurl") is None diff --git a/tests/test_output.py b/tests/test_output.py index 3936f5e8..d3efe182 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -11,7 +11,10 @@ # """Tests for scenedetect.output module.""" +import json +from fractions import Fraction from pathlib import Path +from xml.etree import ElementTree import pytest @@ -28,6 +31,10 @@ VideoMetadata, is_ffmpeg_available, split_video_ffmpeg, + write_scene_list_edl, + write_scene_list_fcp7, + write_scene_list_fcpx, + write_scene_list_otio, ) FFMPEG_ARGS = ( @@ -223,3 +230,210 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): total_images += 1 assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +# +# Scene-list export API (EDL / FCPXML / FCP7 xmeml / OTIO) +# +# These tests construct small synthetic scene lists so they do not require video +# decoding and stay fast. They assert the structural invariants each format must +# hold (e.g. rational time strings for FCPXML, `file://` URIs for xmeml, OTIO +# Clip.2 count matching scene count). + +_FPS_NTSC = Fraction(24000, 1001) +_FPS_CFR = Fraction(30, 1) + + +def _fake_scenes(fps: Fraction, frames): + return [(FrameTimecode(start, fps=fps), FrameTimecode(end, fps=fps)) for start, end in frames] + + +def test_write_scene_list_edl(tmp_path: Path): + """EDL output has title header, FCM line, and one event per scene in CMX 3600 format.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, title="my-clip", reel="AX") + + content = output_path.read_text() + assert "TITLE: my-clip" in content + assert "FCM: NON-DROP FRAME" in content + assert "001 AX V C 00:00:00:00 00:00:01:00 00:00:00:00 00:00:01:00" in content + assert "002 AX V C 00:00:01:00 00:00:02:00 00:00:01:00 00:00:02:00" in content + + +def test_write_scene_list_edl_accepts_str_path(tmp_path: Path): + """`output_path` must accept both Path and str.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(str(output_path), scenes) + assert output_path.exists() + + +def test_write_scene_list_fcpx(tmp_path: Path): + """FCPXML output declares version 1.9, rational time strings, and an asset-clip per scene.""" + scenes = _fake_scenes(_FPS_NTSC, [(48, 96), (96, 144)]) + output_path = tmp_path / "scenes.xml" + # `video_path` need not exist; only `.absolute().as_uri()` is called on it. + write_scene_list_fcpx( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "fake_video.mp4", + frame_rate=_FPS_NTSC, + frame_size=(1280, 544), + ) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "fcpxml" + assert root.attrib["version"] == "1.9" + + fmt = root.find("resources/format") + assert fmt is not None + # 24000/1001 fps → frameDuration is the reciprocal: 1001/24000s. + assert fmt.attrib["frameDuration"] == "1001/24000s" + assert fmt.attrib["width"] == "1280" + assert fmt.attrib["height"] == "544" + + media_rep = root.find("resources/asset/media-rep") + assert media_rep is not None + assert media_rep.attrib["src"].startswith("file://") + + clips = root.findall("library/event/project/sequence/spine/asset-clip") + assert len(clips) == 2 + for clip in clips: + for attr in ("offset", "start", "duration"): + assert clip.attrib[attr].endswith("s") + + +def test_write_scene_list_fcpx_video_name_defaults_to_path_stem(tmp_path: Path): + """Omitting `video_name` falls back to the stem of `video_path`.""" + scenes = _fake_scenes(_FPS_NTSC, [(0, 24)]) + output_path = tmp_path / "scenes.xml" + write_scene_list_fcpx( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "my_clip.mp4", + frame_rate=_FPS_NTSC, + frame_size=(640, 360), + ) + root = ElementTree.parse(output_path).getroot() + asset = root.find("resources/asset") + assert asset is not None and asset.attrib["name"] == "my_clip" + + +def test_write_scene_list_fcp7(tmp_path: Path): + """FCP7 xmeml declares version 5, a clipitem per scene, and a shared reference.""" + scenes = _fake_scenes(_FPS_NTSC, [(0, 48), (48, 96)]) + output_path = tmp_path / "scenes.xml" + write_scene_list_fcp7( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "source.mp4", + frame_rate=_FPS_NTSC, + frame_size=(1920, 1080), + source_duration=FrameTimecode(240, fps=_FPS_NTSC), + ) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "xmeml" + assert root.attrib["version"] == "5" + + ntsc = root.find("project/sequence/rate/ntsc") + assert ntsc is not None and ntsc.text == "True" + + clipitems = root.findall("project/sequence/media/video/track/clipitem") + assert len(clipitems) == 2 + # First clipitem carries the full declaration; later ones reference it by id. + first_file = clipitems[0].find("file") + assert first_file is not None and first_file.attrib["id"] == "file1" + pathurl = first_file.find("pathurl") + assert pathurl is not None and pathurl.text is not None + assert pathurl.text.startswith("file://") + assert first_file.find("duration") is not None + second_file = clipitems[1].find("file") + assert second_file is not None and second_file.attrib["id"] == "file1" + assert second_file.find("pathurl") is None + + +def test_write_scene_list_fcp7_cfr_sets_ntsc_false(tmp_path: Path): + """Integer frame rates (denominator == 1) must set ntsc="False".""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.xml" + write_scene_list_fcp7( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "source.mp4", + frame_rate=_FPS_CFR, + frame_size=(640, 360), + ) + root = ElementTree.parse(output_path).getroot() + ntsc = root.find("project/sequence/rate/ntsc") + assert ntsc is not None and ntsc.text == "False" + + +def test_write_scene_list_otio(tmp_path: Path): + """OTIO output is valid JSON with a Timeline.1 schema and one Clip.2 per scene per track.""" + scenes = _fake_scenes(_FPS_NTSC, [(24, 72), (72, 120)]) + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "clip.mp4", + frame_rate=_FPS_NTSC, + name="my-timeline", + ) + + doc = json.loads(output_path.read_text()) + assert doc["OTIO_SCHEMA"] == "Timeline.1" + assert doc["name"] == "my-timeline" + assert doc["global_start_time"]["rate"] == pytest.approx(float(_FPS_NTSC)) + + tracks = doc["tracks"]["children"] + # Default `audio=True` yields both a video and an audio track. + assert [t["kind"] for t in tracks] == ["Video", "Audio"] + for track in tracks: + assert len(track["children"]) == len(scenes) + for clip in track["children"]: + assert clip["OTIO_SCHEMA"] == "Clip.2" + ref = clip["media_references"]["DEFAULT_MEDIA"] + assert ref["OTIO_SCHEMA"] == "ExternalReference.1" + assert Path(ref["target_url"]).is_absolute() + + +def test_write_scene_list_otio_no_audio(tmp_path: Path): + """`audio=False` omits the audio track.""" + scenes = _fake_scenes(_FPS_NTSC, [(0, 24)]) + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "clip.mp4", + frame_rate=_FPS_NTSC, + audio=False, + ) + doc = json.loads(output_path.read_text()) + tracks = doc["tracks"]["children"] + assert [t["kind"] for t in tracks] == ["Video"] + + +def test_write_scene_list_otio_rational_time_precision(tmp_path: Path): + """Serialized frame-count values must be free of sub-10µs float drift (cf. 914ca31).""" + # Frames on integer-frame boundaries under NTSC 24000/1001: seconds * 23.976... + # should land on integers but floats can produce values like 214.00001 without + # the explicit round(..., 6) in the writer. + scenes = _fake_scenes( + _FPS_NTSC, + [(start, start + 24) for start in (0, 24, 48, 96, 120)], + ) + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "clip.mp4", + frame_rate=_FPS_NTSC, + ) + doc = json.loads(output_path.read_text()) + for track in doc["tracks"]["children"]: + for clip in track["children"]: + for key in ("start_time", "duration"): + value = clip["source_range"][key]["value"] + assert value == round(value, 6), f"value {value!r} carries sub-10µs float drift" diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 09aab163..5b0ad57f 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -381,6 +381,32 @@ def test_vfr_edl_export(test_vfr_video: str, tmp_path): assert "001 AX V" in content +@pytest.mark.parametrize("fcp_format", ["fcpx", "fcp7"]) +def test_vfr_fcp_export(test_vfr_video: str, fcp_format: str, tmp_path): + """`save-fcp` should succeed on VFR video and produce well-formed output in either dialect.""" + from xml.etree import ElementTree + + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-fcp", + "--format", + fcp_format, + ] + ) + assert exit_code == 0 + xml_path = next(tmp_path.glob("*.xml")) + root = ElementTree.parse(xml_path).getroot() + assert root.tag == ("fcpxml" if fcp_format == "fcpx" else "xmeml") + + def test_vfr_csv_backend_conformance(test_vfr_video: str): """PyAV and OpenCV should produce identical scene timecodes for VFR video. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e7d61ed0..6f6acf7f 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -676,7 +676,7 @@ Although there have been minimal changes to most API examples, there are several ### CLI Changes - [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters -- [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496) @@ -686,6 +686,7 @@ Although there have been minimal changes to most API examples, there are several **VFR & Timestamp Overhaul:** + * Add `write_scene_list_edl`, `write_scene_list_fcpx`, `write_scene_list_fcp7`, and `write_scene_list_otio` to the `scenedetect.output` module so `save-edl`, `save-fcp`, and `save-otio` can be invoked directly from Python (previously CLI-only) * Add new `Timecode` type to represent frame timings in terms of the video's source timebase * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` From d4fc135bf745a01221b37b4742d8e3d4cfb33b60 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 23 Apr 2026 23:45:23 -0400 Subject: [PATCH 053/163] [actions] Fix ffmpeg install flakiness --- .github/actions/setup-ffmpeg/action.yml | 72 ++++++++++++++++++++----- .github/workflows/build.yml | 4 ++ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml index fbf89347..76cee703 100644 --- a/.github/actions/setup-ffmpeg/action.yml +++ b/.github/actions/setup-ffmpeg/action.yml @@ -9,6 +9,13 @@ inputs: runs: using: 'composite' steps: + - name: Set ffmpeg install config + shell: bash + run: | + VERSION=8.1 + echo "FFMPEG_VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "FFMPEG_ASSET=ffmpeg-${VERSION}-essentials_build.zip" >> "$GITHUB_ENV" + - name: Check for preinstalled ffmpeg id: check shell: bash @@ -26,42 +33,79 @@ runs: if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Linux' }} shell: bash run: | - for attempt in 1 2 3; do + attempts=4 + for attempt in $(seq 1 $attempts); do echo "apt-get attempt $attempt" if sudo apt-get update && sudo apt-get install -y ffmpeg; then exit 0 fi - sleep 10 + if [ "$attempt" -lt "$attempts" ]; then + delay=$(( 15 * (1 << (attempt - 1)) + RANDOM % 6 )) + echo "install failed; sleeping ${delay}s before retry" + sleep "$delay" + fi done - echo "Failed to install ffmpeg via apt-get after 3 attempts" >&2 + echo "Failed to install ffmpeg via apt-get after $attempts attempts" >&2 exit 1 - name: Install ffmpeg (macOS) if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'macOS' }} shell: bash run: | - for attempt in 1 2 3; do + attempts=4 + for attempt in $(seq 1 $attempts); do echo "brew attempt $attempt" if brew install ffmpeg; then exit 0 fi - sleep 10 + if [ "$attempt" -lt "$attempts" ]; then + delay=$(( 15 * (1 << (attempt - 1)) + RANDOM % 6 )) + echo "install failed; sleeping ${delay}s before retry" + sleep "$delay" + fi done - echo "Failed to install ffmpeg via brew after 3 attempts" >&2 + echo "Failed to install ffmpeg via brew after $attempts attempts" >&2 exit 1 - - name: Install ffmpeg (Windows) + - name: Cache ffmpeg (Windows) if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + id: cache-ffmpeg-windows + uses: actions/cache@v4 + with: + path: C:\ffmpeg-bin + key: ffmpeg-windows-${{ env.FFMPEG_VERSION }}-v1 + + - name: Install ffmpeg (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' && steps.cache-ffmpeg-windows.outputs.cache-hit != 'true' }} shell: pwsh + env: + GH_TOKEN: ${{ github.token }} run: | - for ($attempt = 1; $attempt -le 3; $attempt++) { - Write-Host "choco attempt $attempt" - choco install ffmpeg -y --no-progress - if ($LASTEXITCODE -eq 0) { exit 0 } - Start-Sleep -Seconds 10 + $attempts = 4 + $downloaded = $false + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + Write-Host "download attempt $attempt (GyanD/codexffmpeg $env:FFMPEG_VERSION)" + gh release download $env:FFMPEG_VERSION --repo GyanD/codexffmpeg --pattern $env:FFMPEG_ASSET --clobber + if ($LASTEXITCODE -eq 0) { $downloaded = $true; break } + if ($attempt -lt $attempts) { + $delay = [int]([math]::Pow(2, $attempt - 1) * 15) + (Get-Random -Minimum 0 -Maximum 6) + Write-Host "download failed; sleeping ${delay}s before retry" + Start-Sleep -Seconds $delay + } } - Write-Error "Failed to install ffmpeg via choco after 3 attempts" - exit 1 + if (-not $downloaded) { + Write-Error "Failed to download ffmpeg after $attempts attempts" + exit 1 + } + Expand-Archive -Path $env:FFMPEG_ASSET -DestinationPath ffmpeg-extract -Force + New-Item -ItemType Directory -Force -Path C:\ffmpeg-bin | Out-Null + Copy-Item -Path (Join-Path "ffmpeg-extract" "ffmpeg-$env:FFMPEG_VERSION-essentials_build\bin\*.exe") -Destination C:\ffmpeg-bin\ + Remove-Item -Recurse -Force ffmpeg-extract, $env:FFMPEG_ASSET + + - name: Add ffmpeg to PATH (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + shell: pwsh + run: Add-Content -Path $env:GITHUB_PATH -Value 'C:\ffmpeg-bin' - name: Verify ffmpeg shell: bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71ecf7ce..a8ce7bdb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,11 +9,15 @@ on: - dist/** - scenedetect/** - tests/** + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** push: paths: - dist/** - scenedetect/** - tests/** + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** branches: - main - 'releases/**' From 1abaa25d8222aa79b2896d7a292638a442928aae Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 18:54:45 -0400 Subject: [PATCH 054/163] [cli] Fix incorrect deprecation warning for save-html #518 --- scenedetect/_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 59479bcd..74ec70b1 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1028,7 +1028,7 @@ def save_html_command( image_height: ty.Optional[int], show: bool, ): - if ctx.command.name == "save-html": + if ctx.info_name == "export-html": logger.warning("WARNING: export-html is deprecated, use save-html instead.") ctx = ctx.obj assert isinstance(ctx, CliContext) From b8c43bbe9da0a53497478171b27cd90a8daa52bd Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:04:42 -0400 Subject: [PATCH 055/163] [project] Suppress some pyright warnings since many 3p libs don't have stubs --- pyproject.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9ec220e7..e2a9fa51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,18 @@ ignore = [ ] fixable = ["ALL"] unfixable = [] + +[tool.pyright] +include = ["scenedetect", "tests"] +typeCheckingMode = "basic" +# cv2, av, and moviepy ship without type stubs; these reports generate +# unactionable noise without catching real issues in this codebase. +reportMissingTypeStubs = "none" +reportUnknownMemberType = "none" +reportUnknownArgumentType = "none" +reportUnknownVariableType = "none" +reportUnknownParameterType = "none" +reportMissingParameterType = "none" +reportMissingTypeArgument = "none" +reportUntypedFunctionDecorator = "none" +reportPrivateUsage = "none" From 77838573465464931de426c986f2a658c8dfd78b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:15:13 -0400 Subject: [PATCH 056/163] [lint] Add ruff UP rules and migrate all type hints There are some legitimate issues this has surfaced but they are NOT addressed in this commit. This purely changes type hints and string formatting so it should be a no-op. --- benchmark/__main__.py | 2 +- docs/conf.py | 2 +- docs/generate_cli_docs.py | 54 +-- pyproject.toml | 8 +- scenedetect/__init__.py | 8 +- scenedetect/_cli/__init__.py | 494 +++++++++++--------- scenedetect/_cli/commands.py | 13 +- scenedetect/_cli/config.py | 83 ++-- scenedetect/_cli/context.py | 113 ++--- scenedetect/_cli/controller.py | 4 +- scenedetect/_thirdparty/simpletable.py | 9 +- scenedetect/backends/__init__.py | 2 +- scenedetect/backends/moviepy.py | 20 +- scenedetect/backends/opencv.py | 36 +- scenedetect/backends/pyav.py | 26 +- scenedetect/common.py | 22 +- scenedetect/detector.py | 22 +- scenedetect/detectors/adaptive_detector.py | 14 +- scenedetect/detectors/content_detector.py | 16 +- scenedetect/detectors/hash_detector.py | 6 +- scenedetect/detectors/histogram_detector.py | 6 +- scenedetect/detectors/threshold_detector.py | 12 +- scenedetect/detectors/transnet_v2.py | 16 +- scenedetect/output/__init__.py | 64 +-- scenedetect/output/image.py | 44 +- scenedetect/output/video.py | 27 +- scenedetect/platform.py | 20 +- scenedetect/scene_manager.py | 34 +- scenedetect/stats_manager.py | 24 +- scenedetect/video_stream.py | 12 +- tests/conftest.py | 5 +- tests/helpers.py | 2 +- tests/test_api.py | 14 +- tests/test_cli.py | 13 +- tests/test_detectors.py | 15 +- tests/test_scene_manager.py | 2 +- tests/test_vfr.py | 8 +- tests/test_video_stream.py | 33 +- 38 files changed, 672 insertions(+), 633 deletions(-) diff --git a/benchmark/__main__.py b/benchmark/__main__.py index cb92c3ed..91169637 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -115,7 +115,7 @@ def create_parser(): return parser -def run_all_benchmarks(detector: ty.Optional[str], dataset: ty.Optional[str], detailed: bool): +def run_all_benchmarks(detector: str | None, dataset: str | None, detailed: bool): detectors = {detector: _DETECTORS[detector]} if detector else _DETECTORS datasets = {dataset: _DATASETS[dataset]} if dataset else _DATASETS print( diff --git a/docs/conf.py b/docs/conf.py index 72ade37d..a7935b56 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -149,7 +149,7 @@ html_theme = "alabaster" html_theme_options = { "sidebar_width": "235px", - "description": "Version: [%s]" % (release), + "description": f"Version: [{release}]", "show_relbar_bottom": True, "show_relbar_top": False, "github_user": "Breakthrough", diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index 6c5bf13c..f26ae114 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -67,7 +67,7 @@ """ -def patch_help(s: str, commands: ty.List[str]) -> str: +def patch_help(s: str, commands: list[str]) -> str: # Patch some TODOs still not handled correctly below. pos = 0 while True: @@ -81,9 +81,9 @@ def patch_help(s: str, commands: ty.List[str]) -> str: for command in [command for command in commands if command not in INFO_COMMANDS]: def add_link(_match: re.Match, command: str = command) -> str: - return ":ref:`%s `" % (command, command) + return f":ref:`{command} `" - s = re.sub("``%s``(?!\\n)" % command, add_link, s) + s = re.sub(f"``{command}``(?!\\n)", add_link, s) return s @@ -97,7 +97,7 @@ def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator: @dataclass class ReplaceWithReference: - range: ty.Tuple[int, int] + range: tuple[int, int] ref: str ref_type: str @@ -107,10 +107,10 @@ def transform_backquotes(s: str) -> str: def add_backquotes(match: re.Match) -> str: - return "``%s``" % match.string[match.start() : match.end()] + return f"``{match.string[match.start() : match.end()]}``" -def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: +def add_backquotes_with_refs(refs: set[str]) -> ty.Callable[[str], str]: """Returns a transformation function that backquotes command examples, adding backquotes and references to any found options.""" @@ -121,14 +121,14 @@ def _add_backquotes(s: re.Match) -> str: # add cross reference cross_ref = flag.string[flag.start() : flag.end()] option = s.string[s.start() : s.end()] - return ":option:`%s <%s>`" % (option, cross_ref) + return f":option:`{option} <{cross_ref}>`" else: return add_backquotes(s) return _add_backquotes -def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: +def extract_default_value(s: str) -> tuple[str, str | None]: default = re.search(r"\[default: .*\]", s) if default is not None: span = default.span() @@ -136,11 +136,11 @@ def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: s, default = s[: span[0]].strip(), s[span[0] : span[1]][len("[default: ") : -1] # Double-quote any default values that contain spaces. if " " in default and '"' not in default and "," not in default: - default = '"%s"' % default + default = f'"{default}"' return (s, default) -def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: +def transform_add_option_refs(s: str, refs: list[str]) -> str: transform = add_backquotes_with_refs(refs) # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. @@ -153,13 +153,15 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: return s -def format_option(command: click.Command, opt: click.Option, flags: ty.List[str]) -> StrGenerator: +def format_option(command: click.Command, opt: click.Option, flags: list[str]) -> StrGenerator: if isinstance(opt, click.Argument): - yield "\n.. option:: %s\n" % opt.name + yield f"\n.. option:: {opt.name}\n" return - yield "\n.. option:: %s\n" % ", ".join( - arg if opt.metavar is None else "%s %s" % (arg, opt.metavar) - for arg in sorted(opt.opts, reverse=True) + yield "\n.. option:: {}\n".format( + ", ".join( + arg if opt.metavar is None else f"{arg} {opt.metavar}" + for arg in sorted(opt.opts, reverse=True) + ) ) help = ( @@ -172,23 +174,23 @@ def format_option(command: click.Command, opt: click.Option, flags: ty.List[str] help, default = extract_default_value(help) help = transform_add_option_refs(help, flags) - yield "\n %s\n" % help + yield f"\n {help}\n" if default is not None: - yield "\n Default: ``%s``\n" % default + yield f"\n Default: ``{default}``\n" def generate_command_help( - ctx: click.Context, command: click.Command, parent_name: ty.Optional[str] = None + ctx: click.Context, command: click.Command, parent_name: str | None = None ) -> StrGenerator: # TODO: Add references to long options. Requires splitting out examples. # TODO: Add references to subcommands. Need to add actual refs, since programs can't be ref'd. # TODO: Handle dollar signs in examples by having both escaped and unescaped versions - yield "\n.. _command-%s:\n" % command.name + yield f"\n.. _command-{command.name}:\n" yield "\n.. program:: %s\n\n" % ( - command.name if parent_name is None else "%s %s" % (parent_name, command.name) + command.name if parent_name is None else f"{parent_name} {command.name}" ) if parent_name: - yield from generate_title("``%s``" % command.name, 1) + yield from generate_title(f"``{command.name}``", 1) replacements = [ opt @@ -209,9 +211,9 @@ def generate_command_help( if line.startswith(INDENT): indent = line.count(INDENT) line = line.strip() - yield "%s``%s``\n" % (indent * INDENT, line) if line else "\n" + yield f"{indent * INDENT}``{line}``\n" if line else "\n" else: - yield "%s\n" % line + yield f"{line}\n" if command.params: yield "\n" @@ -221,7 +223,7 @@ def generate_command_help( yield "\n" -def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGenerator: +def generate_subcommands(ctx: click.Context, commands: list[str]) -> StrGenerator: processed = set() for info_command in INFO_COMMANDS: @@ -248,10 +250,10 @@ def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGener assert set(commands) == processed -def create_help() -> ty.Tuple[str, ty.List[str]]: +def create_help() -> tuple[str, list[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) - commands: ty.List[str] = ctx.command.list_commands(ctx) + commands: list[str] = ctx.command.list_commands(ctx) commands = list( filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands) ) diff --git a/pyproject.toml b/pyproject.toml index e2a9fa51..8fa5a4b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,9 @@ select = [ "F", # isort "I", - # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # pyupgrade - #"UP", + "UP", + # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # flake8-simplify #"SIM", ] @@ -48,6 +48,10 @@ ignore = [ fixable = ["ALL"] unfixable = [] +[tool.ruff.lint.per-file-ignores] +# Vendored third-party code: don't rewrite/modernize upstream source. +"scenedetect/_thirdparty/*" = ["UP"] + [tool.pyright] include = ["scenedetect", "tests"] typeCheckingMode = "basic" diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 59fd44d6..e913d682 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -81,7 +81,7 @@ def open_video( path: str, - framerate: ty.Optional[float] = None, + framerate: float | None = None, backend: str = "opencv", **kwargs, ) -> VideoStream: @@ -135,10 +135,10 @@ def open_video( def detect( video_path: str, detector: SceneDetector, - stats_file_path: ty.Optional[str] = None, + stats_file_path: str | None = None, show_progress: bool = False, - start_time: ty.Optional[ty.Union[str, float, int]] = None, - end_time: ty.Optional[ty.Union[str, float, int]] = None, + start_time: str | float | int | None = None, + end_time: str | float | int | None = None, start_in_scene: bool = False, ) -> SceneList: """Perform scene detection on a given video `path` using the specified `detector`. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 74ec70b1..1e1abc22 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -95,7 +95,7 @@ class Command(click.Command): def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help into the formatter if it exists.""" if ctx.parent: - formatter.write(click.style("`%s` Command" % ctx.command.name, fg="cyan")) + formatter.write(click.style(f"`{ctx.command.name}` Command", fg="cyan")) formatter.write_paragraph() formatter.write(click.style(LINE_SEPARATOR, fg="cyan")) formatter.write_paragraph() @@ -117,7 +117,7 @@ def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) - if self.help: base_command = ctx.parent.info_name if ctx.parent is not None else ctx.info_name formatted_help = self.help.format( - scenedetect=base_command, scenedetect_with_video="%s -i video.mp4" % base_command + scenedetect=base_command, scenedetect_with_video=f"{base_command} -i video.mp4" ) text = inspect.cleandoc(formatted_help).partition("\f")[0] formatter.write_paragraph() @@ -198,15 +198,16 @@ def print_command_help(ctx: click.Context, command: click.Command): required=False, metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s" - % (USER_CONFIG.get_help_string("global", "output", show_default=False)), + help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.{}".format( + USER_CONFIG.get_help_string("global", "output", show_default=False) + ), ) @click.option( "--config", "-c", metavar="FILE", type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help="Path to config file. If unset, tries to load config from %s" % (CONFIG_FILE_PATH), + help=f"Path to config file. If unset, tries to load config from {CONFIG_FILE_PATH}", ) @click.option( "--stats", @@ -229,24 +230,27 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633).%s" - % USER_CONFIG.get_help_string("global", "min-scene-len"), + help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633).{}".format( + USER_CONFIG.get_help_string("global", "min-scene-len") + ), ) @click.option( "--drop-short-scenes", is_flag=True, flag_value=True, default=None, - help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" - % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), + help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.{}".format( + USER_CONFIG.get_help_string("global", "drop-short-scenes") + ), ) @click.option( "--merge-last-scene", is_flag=True, flag_value=True, default=None, - help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" - % (USER_CONFIG.get_help_string("global", "merge-last-scene")), + help="Merge last scene with previous if shorter than -m/--min-scene-len.{}".format( + USER_CONFIG.get_help_string("global", "merge-last-scene") + ), ) @click.option( "--backend", @@ -254,16 +258,18 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="BACKEND", type=click.Choice(CHOICE_MAP["global"]["backend"]), default=None, - help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" - % (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), + help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: {}]{}".format( + ", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend") + ), ) @click.option( "--crop", metavar="X0 Y0 X1 Y1", type=(int, int, int, int), default=None, - help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).%s" - % (USER_CONFIG.get_help_string("global", "crop", show_default=False)), + help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).{}".format( + USER_CONFIG.get_help_string("global", "crop", show_default=False) + ), ) @click.option( "--downscale", @@ -271,8 +277,9 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="N", type=click.INT, default=None, - help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling.%s" - % (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), + help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling.{}".format( + USER_CONFIG.get_help_string("global", "downscale", show_default=False) + ), ) @click.option( "--frame-skip", @@ -280,8 +287,9 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="N", type=click.INT, default=None, - help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs 1 skips every other frame processing 50%% of the video, -fs 2 processes 33%% of the video frames, -fs 3 processes 25%%, etc... %s" - % USER_CONFIG.get_help_string("global", "frame-skip"), + help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs 1 skips every other frame processing 50% of the video, -fs 2 processes 33% of the video frames, -fs 3 processes 25%, etc... {}".format( + USER_CONFIG.get_help_string("global", "frame-skip") + ), ) @click.option( "--verbosity", @@ -289,8 +297,7 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="LEVEL", type=click.Choice(CHOICE_MAP["global"]["verbosity"], False), default=None, - help="Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s" - % ( + help="Amount of information to show. LEVEL must be one of: {}. Overrides -q/--quiet.{}".format( ", ".join(CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string("global", "verbosity"), ), @@ -312,20 +319,20 @@ def print_command_help(ctx: click.Context, command: click.Command): @click.pass_context def scenedetect( ctx: click.Context, - input: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], - stats: ty.Optional[ty.AnyStr], - config: ty.Optional[ty.AnyStr], - framerate: ty.Optional[float], - min_scene_len: ty.Optional[str], - drop_short_scenes: ty.Optional[bool], - merge_last_scene: ty.Optional[bool], - backend: ty.Optional[str], - crop: ty.Optional[ty.Tuple[int, int, int, int]], - downscale: ty.Optional[int], - frame_skip: ty.Optional[int], - verbosity: ty.Optional[str], - logfile: ty.Optional[ty.AnyStr], + input: ty.AnyStr | None, + output: ty.AnyStr | None, + stats: ty.AnyStr | None, + config: ty.AnyStr | None, + framerate: float | None, + min_scene_len: str | None, + drop_short_scenes: bool | None, + merge_last_scene: bool | None, + backend: str | None, + crop: tuple[int, int, int, int] | None, + downscale: int | None, + frame_skip: int | None, + verbosity: str | None, + logfile: ty.AnyStr | None, quiet: bool, ): ctx = ctx.obj @@ -375,7 +382,7 @@ def help_command(ctx: click.Context, command_name: str): if command_name not in all_commands: error_strs = [ "unknown command. List of valid commands:", - " %s" % ", ".join(sorted(all_commands)), + " {}".format(", ".join(sorted(all_commands))), ] raise click.BadParameter("\n".join(error_strs), param_hint="command") click.echo("") @@ -393,7 +400,7 @@ def about_command(ctx: click.Context): """Print license/copyright info.""" click.echo("") click.echo(click.style(LINE_SEPARATOR, fg="cyan")) - click.echo(click.style(" About PySceneDetect %s" % PROGRAM_VERSION, fg="yellow")) + click.echo(click.style(f" About PySceneDetect {PROGRAM_VERSION}", fg="yellow")) click.echo(click.style(LINE_SEPARATOR, fg="cyan")) click.echo(ABOUT_STRING) ctx.exit() @@ -450,9 +457,9 @@ def version_command(ctx: click.Context): @click.pass_context def time_command( ctx: click.Context, - start: ty.Optional[str], - duration: ty.Optional[str], - end: ty.Optional[str], + start: str | None, + duration: str | None, + end: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -507,8 +514,9 @@ def time_command( CONFIG_MAP["detect-content"]["threshold"].max_val, ), default=None, - help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-content", "threshold")), + help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-content", "threshold") + ), ) @click.option( "--weights", @@ -516,16 +524,18 @@ def time_command( type=(float, float, float, float), default=None, metavar="HUE SAT LUM EDGE", - help="Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).%s" - % (USER_CONFIG.get_help_string("detect-content", "weights")), + help="Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).{}".format( + USER_CONFIG.get_help_string("detect-content", "weights") + ), ) @click.option( "--luma-only", "-l", is_flag=True, flag_value=True, - help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0.%s" - % (USER_CONFIG.get_help_string("detect-content", "luma-only")), + help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0.{}".format( + USER_CONFIG.get_help_string("detect-content", "luma-only") + ), ) @click.option( "--kernel-size", @@ -533,8 +543,9 @@ def time_command( metavar="N", type=click.INT, default=None, - help="Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.%s" - % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), + help="Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.{}".format( + USER_CONFIG.get_help_string("detect-content", "kernel-size") + ), ) @click.option( "--min-scene-len", @@ -555,8 +566,7 @@ def time_command( metavar="MODE", type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), default=None, - help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" - % ( + help="Mode used to enforce -m/--min-scene-len option. Can be one of: {}. {}".format( ", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), USER_CONFIG.get_help_string("detect-content", "filter-mode"), ), @@ -564,12 +574,12 @@ def time_command( @click.pass_context def detect_content_command( ctx: click.Context, - threshold: ty.Optional[float], - weights: ty.Optional[ty.Tuple[float, float, float, float]], + threshold: float | None, + weights: tuple[float, float, float, float] | None, luma_only: bool, - kernel_size: ty.Optional[int], - min_scene_len: ty.Optional[str], - filter_mode: ty.Optional[str], + kernel_size: int | None, + min_scene_len: str | None, + filter_mode: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -603,8 +613,9 @@ def detect_content_command( metavar="VAL", type=click.FLOAT, default=None, - help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-adaptive", "threshold")), + help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-adaptive", "threshold") + ), ) @click.option( "--min-content-val", @@ -612,8 +623,9 @@ def detect_content_command( metavar="VAL", type=click.FLOAT, default=None, - help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' - % (USER_CONFIG.get_help_string("detect-adaptive", "min-content-val")), + help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.{}'.format( + USER_CONFIG.get_help_string("detect-adaptive", "min-content-val") + ), ) @click.option( "--frame-window", @@ -621,24 +633,27 @@ def detect_content_command( metavar="VAL", type=click.INT, default=None, - help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s" - % (USER_CONFIG.get_help_string("detect-adaptive", "frame-window")), + help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.{}".format( + USER_CONFIG.get_help_string("detect-adaptive", "frame-window") + ), ) @click.option( "--weights", "-w", type=(float, float, float, float), default=None, - help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".%s' - % (USER_CONFIG.get_help_string("detect-content", "weights")), + help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".{}'.format( + USER_CONFIG.get_help_string("detect-content", "weights") + ), ) @click.option( "--luma-only", "-l", is_flag=True, flag_value=True, - help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".%s' - % (USER_CONFIG.get_help_string("detect-content", "luma-only")), + help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".{}'.format( + USER_CONFIG.get_help_string("detect-content", "luma-only") + ), ) @click.option( "--kernel-size", @@ -646,8 +661,9 @@ def detect_content_command( metavar="N", type=click.INT, default=None, - help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s" - % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), + help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.{}".format( + USER_CONFIG.get_help_string("detect-content", "kernel-size") + ), ) @click.option( "--min-scene-len", @@ -665,13 +681,13 @@ def detect_content_command( @click.pass_context def detect_adaptive_command( ctx: click.Context, - threshold: ty.Optional[float], - min_content_val: ty.Optional[float], - frame_window: ty.Optional[int], - weights: ty.Optional[ty.Tuple[float, float, float, float]], + threshold: float | None, + min_content_val: float | None, + frame_window: int | None, + weights: tuple[float, float, float, float] | None, luma_only: bool, - kernel_size: ty.Optional[int], - min_scene_len: ty.Optional[str], + kernel_size: int | None, + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -709,8 +725,9 @@ def detect_adaptive_command( CONFIG_MAP["detect-threshold"]["threshold"].max_val, ), default=None, - help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-threshold", "threshold")), + help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-threshold", "threshold") + ), ) @click.option( "--fade-bias", @@ -721,16 +738,18 @@ def detect_adaptive_command( CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, ), default=None, - help="Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s" - % (USER_CONFIG.get_help_string("detect-threshold", "fade-bias")), + help="Percent (%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.{}".format( + USER_CONFIG.get_help_string("detect-threshold", "fade-bias") + ), ) @click.option( "--add-last-scene", "-l", is_flag=True, flag_value=True, - help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s" - % (USER_CONFIG.get_help_string("detect-threshold", "add-last-scene")), + help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.{}".format( + USER_CONFIG.get_help_string("detect-threshold", "add-last-scene") + ), ) @click.option( "--min-scene-len", @@ -748,10 +767,10 @@ def detect_adaptive_command( @click.pass_context def detect_threshold_command( ctx: click.Context, - threshold: ty.Optional[float], - fade_bias: ty.Optional[float], + threshold: float | None, + fade_bias: float | None, add_last_scene: bool, - min_scene_len: ty.Optional[str], + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -789,8 +808,9 @@ def detect_threshold_command( ), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " - "values are more sensitive to changes.%s" - % (USER_CONFIG.get_help_string("detect-hist", "threshold")), + "values are more sensitive to changes.{}".format( + USER_CONFIG.get_help_string("detect-hist", "threshold") + ), ) @click.option( "--bins", @@ -800,8 +820,9 @@ def detect_threshold_command( CONFIG_MAP["detect-hist"]["bins"].min_val, CONFIG_MAP["detect-hist"]["bins"].max_val ), default=None, - help="The number of bins to use for the histogram calculation.%s" - % (USER_CONFIG.get_help_string("detect-hist", "bins")), + help="The number of bins to use for the histogram calculation.{}".format( + USER_CONFIG.get_help_string("detect-hist", "bins") + ), ) @click.option( "--min-scene-len", @@ -821,9 +842,9 @@ def detect_threshold_command( @click.pass_context def detect_hist_command( ctx: click.Context, - threshold: ty.Optional[float], - bins: ty.Optional[int], - min_scene_len: ty.Optional[str], + threshold: float | None, + bins: int | None, + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -859,7 +880,9 @@ def detect_hist_command( default=None, help=( "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " - "more sensitive to changes.%s" % (USER_CONFIG.get_help_string("detect-hash", "threshold")) + "more sensitive to changes.{}".format( + USER_CONFIG.get_help_string("detect-hash", "threshold") + ) ), ) @click.option( @@ -870,8 +893,9 @@ def detect_hist_command( CONFIG_MAP["detect-hash"]["size"].min_val, CONFIG_MAP["detect-hash"]["size"].max_val ), default=None, - help="Size of square of low frequency data to include from the discrete cosine transform.%s" - % (USER_CONFIG.get_help_string("detect-hash", "size")), + help="Size of square of low frequency data to include from the discrete cosine transform.{}".format( + USER_CONFIG.get_help_string("detect-hash", "size") + ), ) @click.option( "--lowpass", @@ -883,8 +907,9 @@ def detect_hist_command( default=None, help=( "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " - "the frequency data, 4 means only keep 1/4, etc...%s" - % (USER_CONFIG.get_help_string("detect-hash", "lowpass")) + "the frequency data, 4 means only keep 1/4, etc...{}".format( + USER_CONFIG.get_help_string("detect-hash", "lowpass") + ) ), ) @click.option( @@ -905,10 +930,10 @@ def detect_hist_command( @click.pass_context def detect_hash_command( ctx: click.Context, - threshold: ty.Optional[float], - size: ty.Optional[int], - lowpass: ty.Optional[int], - min_scene_len: ty.Optional[str], + threshold: float | None, + size: int | None, + lowpass: int | None, + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -944,13 +969,12 @@ def detect_hash_command( metavar="STRING", type=click.STRING, default=None, - help="Name of column used to mark scene cuts.%s" - % (USER_CONFIG.get_help_string("load-scenes", "start-col-name")), + help="Name of column used to mark scene cuts.{}".format( + USER_CONFIG.get_help_string("load-scenes", "start-col-name") + ), ) @click.pass_context -def load_scenes_command( - ctx: click.Context, input: ty.Optional[str], start_col_name: ty.Optional[str] -): +def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: str | None): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -983,32 +1007,36 @@ def load_scenes_command( metavar="NAME", default="$VIDEO_NAME-Scenes.html", type=click.STRING, - help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s" - % (USER_CONFIG.get_help_string("save-html", "filename")), + help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.{}".format( + USER_CONFIG.get_help_string("save-html", "filename") + ), ) @click.option( "--no-images", "-n", is_flag=True, flag_value=True, - help="Do not include images with the result.%s" - % (USER_CONFIG.get_help_string("save-html", "no-images")), + help="Do not include images with the result.{}".format( + USER_CONFIG.get_help_string("save-html", "no-images") + ), ) @click.option( "--image-width", "-w", metavar="pixels", type=click.INT, - help="Width in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("save-html", "image-width", show_default=False)), + help="Width in pixels of the images in the resulting HTML table.{}".format( + USER_CONFIG.get_help_string("save-html", "image-width", show_default=False) + ), ) @click.option( "--image-height", "-h", metavar="pixels", type=click.INT, - help="Height in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("save-html", "image-height", show_default=False)), + help="Height in pixels of the images in the resulting HTML table.{}".format( + USER_CONFIG.get_help_string("save-html", "image-height", show_default=False) + ), ) @click.option( "--show", @@ -1016,16 +1044,17 @@ def load_scenes_command( is_flag=True, flag_value=True, default=None, - help="Automatically open resulting HTML when processing is complete.%s" - % (USER_CONFIG.get_help_string("save-html", "show")), + help="Automatically open resulting HTML when processing is complete.{}".format( + USER_CONFIG.get_help_string("save-html", "show") + ), ) @click.pass_context def save_html_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, no_images: bool, - image_width: ty.Optional[int], - image_height: ty.Optional[int], + image_width: int | None, + image_height: int | None, show: bool, ): if ctx.info_name == "export-html": @@ -1067,8 +1096,9 @@ def save_html_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("list-scenes", "output", show_default=False) + ), ) @click.option( "--filename", @@ -1076,8 +1106,9 @@ def save_html_command( metavar="NAME", default="$VIDEO_NAME-Scenes.csv", type=click.STRING, - help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f \\$VIDEO_NAME-Scenes.csv).%s" - % (USER_CONFIG.get_help_string("list-scenes", "filename")), + help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f \\$VIDEO_NAME-Scenes.csv).{}".format( + USER_CONFIG.get_help_string("list-scenes", "filename") + ), ) @click.option( "--no-output-file", @@ -1085,8 +1116,9 @@ def save_html_command( is_flag=True, flag_value=True, default=None, - help="Only print scene list.%s" - % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), + help="Only print scene list.{}".format( + USER_CONFIG.get_help_string("list-scenes", "no-output-file") + ), ) @click.option( "--quiet", @@ -1094,7 +1126,9 @@ def save_html_command( is_flag=True, flag_value=True, default=None, - help="Suppress printing scene list.%s" % (USER_CONFIG.get_help_string("list-scenes", "quiet")), + help="Suppress printing scene list.{}".format( + USER_CONFIG.get_help_string("list-scenes", "quiet") + ), ) @click.option( "--skip-cuts", @@ -1102,17 +1136,18 @@ def save_html_command( is_flag=True, flag_value=True, default=None, - help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s" - % (USER_CONFIG.get_help_string("list-scenes", "skip-cuts")), + help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.{}".format( + USER_CONFIG.get_help_string("list-scenes", "skip-cuts") + ), ) @click.pass_context def list_scenes_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - no_output_file: ty.Optional[bool], - quiet: ty.Optional[bool], - skip_cuts: ty.Optional[bool], + output: ty.AnyStr | None, + filename: ty.AnyStr | None, + no_output_file: bool | None, + quiet: bool | None, + skip_cuts: bool | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1156,8 +1191,9 @@ def list_scenes_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("split-video", "output", show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("split-video", "output", show_default=False) + ), ) @click.option( "--filename", @@ -1165,8 +1201,9 @@ def list_scenes_command( metavar="NAME", default=None, type=click.STRING, - help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f \\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" - % (USER_CONFIG.get_help_string("split-video", "filename")), + help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f \\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).{}".format( + USER_CONFIG.get_help_string("split-video", "filename") + ), ) @click.option( "--quiet", @@ -1174,24 +1211,27 @@ def list_scenes_command( is_flag=True, flag_value=True, default=False, - help="Hide output from external video splitting tool.%s" - % (USER_CONFIG.get_help_string("split-video", "quiet")), + help="Hide output from external video splitting tool.{}".format( + USER_CONFIG.get_help_string("split-video", "quiet") + ), ) @click.option( "--copy", "-c", is_flag=True, flag_value=True, - help="Copy instead of re-encode. Faster but less precise.%s" - % (USER_CONFIG.get_help_string("split-video", "copy")), + help="Copy instead of re-encode. Faster but less precise.{}".format( + USER_CONFIG.get_help_string("split-video", "copy") + ), ) @click.option( "--high-quality", "-hq", is_flag=True, flag_value=True, - help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s" - % (USER_CONFIG.get_help_string("split-video", "high-quality")), + help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow{}".format( + USER_CONFIG.get_help_string("split-video", "high-quality") + ), ) @click.option( "--rate-factor", @@ -1202,8 +1242,9 @@ def list_scenes_command( CONFIG_MAP["split-video"]["rate-factor"].min_val, CONFIG_MAP["split-video"]["rate-factor"].max_val, ), - help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s" - % (USER_CONFIG.get_help_string("split-video", "rate-factor")), + help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.{}".format( + USER_CONFIG.get_help_string("split-video", "rate-factor") + ), ) @click.option( "--preset", @@ -1211,8 +1252,7 @@ def list_scenes_command( metavar="LEVEL", default=None, type=click.Choice(CHOICE_MAP["split-video"]["preset"]), - help="Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s" - % ( + help="Video compression quality (x264 preset). Can be one of: {}. Faster modes take less time but output may be larger.{}".format( ", ".join(CHOICE_MAP["split-video"]["preset"]), USER_CONFIG.get_help_string("split-video", "preset"), ), @@ -1223,28 +1263,30 @@ def list_scenes_command( metavar="ARGS", type=click.STRING, default=None, - help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.%s' - % (USER_CONFIG.get_help_string("split-video", "args")), + help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.{}'.format( + USER_CONFIG.get_help_string("split-video", "args") + ), ) @click.option( "--mkvmerge", "-m", is_flag=True, flag_value=True, - help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s" - % (USER_CONFIG.get_help_string("split-video", "mkvmerge")), + help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.{}".format( + USER_CONFIG.get_help_string("split-video", "mkvmerge") + ), ) @click.pass_context def split_video_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], + output: ty.AnyStr | None, + filename: ty.AnyStr | None, quiet: bool, copy: bool, high_quality: bool, - rate_factor: ty.Optional[int], - preset: ty.Optional[str], - args: ty.Optional[str], + rate_factor: int | None, + preset: str | None, + args: str | None, mkvmerge: bool, ): ctx = ctx.obj @@ -1270,20 +1312,20 @@ def split_video_command( command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" if high_quality: raise click.BadParameter( - "high-quality (-hq) cannot be used with %s" % (command), + f"high-quality (-hq) cannot be used with {command}", param_hint="split-video", ) if args: raise click.BadParameter( - "args (-a) cannot be used with %s" % (command), param_hint="split-video" + f"args (-a) cannot be used with {command}", param_hint="split-video" ) if rate_factor: raise click.BadParameter( - "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" + f"rate-factor (crf) cannot be used with {command}", param_hint="split-video" ) if preset: raise click.BadParameter( - "preset (-p) cannot be used with %s" % (command), param_hint="split-video" + f"preset (-p) cannot be used with {command}", param_hint="split-video" ) # mkvmerge-Specific Options @@ -1333,8 +1375,9 @@ def split_video_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory for images. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-images", "output", show_default=False)), + help="Output directory for images. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-images", "output", show_default=False) + ), ) @click.option( "--filename", @@ -1342,8 +1385,9 @@ def split_video_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f \\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" - % (USER_CONFIG.get_help_string("save-images", "filename")), + help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f \\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.{}".format( + USER_CONFIG.get_help_string("save-images", "filename") + ), ) @click.option( "--num-images", @@ -1351,16 +1395,18 @@ def split_video_command( metavar="N", default=None, type=click.INT, - help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene.%s" - % (USER_CONFIG.get_help_string("save-images", "num-images")), + help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene.{}".format( + USER_CONFIG.get_help_string("save-images", "num-images") + ), ) @click.option( "--jpeg", "-j", is_flag=True, flag_value=True, - help="Set output format to JPEG (default).%s" - % (USER_CONFIG.get_help_string("save-images", "format", show_default=False)), + help="Set output format to JPEG (default).{}".format( + USER_CONFIG.get_help_string("save-images", "format", show_default=False) + ), ) @click.option( "--webp", @@ -1375,8 +1421,9 @@ def split_video_command( metavar="Q", default=None, type=click.IntRange(0, 100), - help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s" - % (USER_CONFIG.get_help_string("save-images", "quality", show_default=False)), + help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]{}".format( + USER_CONFIG.get_help_string("save-images", "quality", show_default=False) + ), ) @click.option( "--png", @@ -1391,8 +1438,9 @@ def split_video_command( metavar="C", default=None, type=click.IntRange(0, 9), - help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s" - % (USER_CONFIG.get_help_string("save-images", "compression")), + help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.{}".format( + USER_CONFIG.get_help_string("save-images", "compression") + ), ) @click.option( "-m", @@ -1400,8 +1448,9 @@ def split_video_command( metavar="DURATION", default=None, type=click.STRING, - help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).%s" - % (USER_CONFIG.get_help_string("save-images", "frame-margin")), + help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).{}".format( + USER_CONFIG.get_help_string("save-images", "frame-margin") + ), ) @click.option( "--scale", @@ -1409,8 +1458,9 @@ def split_video_command( metavar="S", default=None, type=click.FLOAT, - help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s" - % (USER_CONFIG.get_help_string("save-images", "scale", show_default=False)), + help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.{}".format( + USER_CONFIG.get_help_string("save-images", "scale", show_default=False) + ), ) @click.option( "--height", @@ -1418,8 +1468,9 @@ def split_video_command( metavar="H", default=None, type=click.INT, - help="Height (pixels) of images.%s" - % (USER_CONFIG.get_help_string("save-images", "height", show_default=False)), + help="Height (pixels) of images.{}".format( + USER_CONFIG.get_help_string("save-images", "height", show_default=False) + ), ) @click.option( "--width", @@ -1427,24 +1478,25 @@ def split_video_command( metavar="W", default=None, type=click.INT, - help="Width (pixels) of images.%s" - % (USER_CONFIG.get_help_string("save-images", "width", show_default=False)), + help="Width (pixels) of images.{}".format( + USER_CONFIG.get_help_string("save-images", "width", show_default=False) + ), ) @click.pass_context def save_images_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr] = None, - filename: ty.Optional[ty.AnyStr] = None, - num_images: ty.Optional[int] = None, + output: ty.AnyStr | None = None, + filename: ty.AnyStr | None = None, + num_images: int | None = None, jpeg: bool = False, webp: bool = False, - quality: ty.Optional[int] = None, + quality: int | None = None, png: bool = False, - compression: ty.Optional[int] = None, - frame_margin: ty.Optional[str] = None, - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, + compression: int | None = None, + frame_margin: str | None = None, + scale: float | None = None, + height: int | None = None, + width: int | None = None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1478,7 +1530,7 @@ def save_images_command( valid_params = get_cv2_imwrite_params() if image_extension not in valid_params or valid_params[image_extension] is None: error_strs = [ - "Image encoder type `%s` not supported." % image_extension.upper(), + f"Image encoder type `{image_extension.upper()}` not supported.", "The specified encoder type could not be found in the current OpenCV module.", "To enable this output format, please update the installed version of OpenCV.", "If you build OpenCV, ensure the the proper dependencies are enabled. ", @@ -1518,7 +1570,7 @@ def save_images_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-edl", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-edl", "filename")), ) @click.option( "--title", @@ -1526,7 +1578,7 @@ def save_images_command( metavar="NAME", default=None, type=click.STRING, - help="Title format to use.%s" % (USER_CONFIG.get_help_string("save-edl", "title")), + help="Title format to use.{}".format(USER_CONFIG.get_help_string("save-edl", "title")), ) @click.option( "--reel", @@ -1534,23 +1586,24 @@ def save_images_command( metavar="REEL", default=None, type=click.STRING, - help="Reel name to use.%s" % (USER_CONFIG.get_help_string("save-edl", "reel")), + help="Reel name to use.{}".format(USER_CONFIG.get_help_string("save-edl", "reel")), ) @click.option( "--output", "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save EDL file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-edl", "output", show_default=False)), + help="Output directory to save EDL file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-edl", "output", show_default=False) + ), ) @click.pass_context def save_edl_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - title: ty.Optional[ty.AnyStr], - reel: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, + title: ty.AnyStr | None, + reel: ty.AnyStr | None, + output: ty.AnyStr | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1577,15 +1630,16 @@ def save_edl_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-qp", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-qp", "filename")), ) @click.option( "--output", "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save QP file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-qp", "output", show_default=False)), + help="Output directory to save QP file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-qp", "output", show_default=False) + ), ) @click.option( "--disable-shift", @@ -1593,15 +1647,16 @@ def save_edl_command( is_flag=True, flag_value=True, default=None, - help="Disable shifting frame numbers by start time.%s" - % (USER_CONFIG.get_help_string("save-qp", "disable-shift")), + help="Disable shifting frame numbers by start time.{}".format( + USER_CONFIG.get_help_string("save-qp", "disable-shift") + ), ) @click.pass_context def save_qp_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], - disable_shift: ty.Optional[bool], + filename: ty.AnyStr | None, + output: ty.AnyStr | None, + disable_shift: bool | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1624,15 +1679,14 @@ def save_qp_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-fcp", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-fcp", "filename")), ) @click.option( "--format", metavar="TYPE", type=click.Choice(CHOICE_MAP["save-fcp"]["format"], False), default=None, - help="Format to export. TYPE must be one of: %s.%s" - % ( + help="Format to export. TYPE must be one of: {}.{}".format( ", ".join(CHOICE_MAP["save-fcp"]["format"]), USER_CONFIG.get_help_string("save-fcp", "format"), ), @@ -1642,15 +1696,16 @@ def save_qp_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save XML file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-fcp", "output", show_default=False)), + help="Output directory to save XML file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-fcp", "output", show_default=False) + ), ) @click.pass_context def save_fcp_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - format: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, + format: ty.AnyStr | None, + output: ty.AnyStr | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1675,7 +1730,7 @@ def save_fcp_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-otio", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-otio", "filename")), ) @click.option( "--name", @@ -1683,15 +1738,16 @@ def save_fcp_command( metavar="NAME", default=None, type=click.STRING, - help="Name of timeline to use.%s" % (USER_CONFIG.get_help_string("save-otio", "name")), + help="Name of timeline to use.{}".format(USER_CONFIG.get_help_string("save-otio", "name")), ) @click.option( "--output", "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save OTIO file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-otio", "output", show_default=False)), + help="Output directory to save OTIO file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-otio", "output", show_default=False) + ), ) @click.option( "--audio", @@ -1708,9 +1764,9 @@ def save_fcp_command( @click.pass_context def save_otio_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - name: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, + name: ty.AnyStr | None, + output: ty.AnyStr | None, audio: bool, no_audio: bool, ): diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 730ff277..ed4ad920 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -93,7 +93,7 @@ def save_qp( start_frame = context.start_time.frame_num if context.start_time else 0 shift_start = not disable_shift offset = start_frame if shift_start else 0 - with open(qp_path, "wt") as qp_file: + with open(qp_path, "w") as qp_file: qp_file.write(f"{0 if shift_start else start_frame} I -1\n") # Place another I frame at each detected cut. qp_file.writelines(f"{cut.frame_num - offset} I -1\n" for cut in cuts) @@ -151,14 +151,7 @@ def list_scenes( -----------------------------------------------------------------------""", "\n".join( [ - " | %5d | %11d | %s | %11d | %s |" - % ( - i + 1, - start_time.frame_num + 1, - start_time.get_timecode(), - end_time.frame_num, - end_time.get_timecode(), - ) + f" | {i + 1:5d} | {start_time.frame_num + 1:11d} | {start_time.get_timecode()} | {end_time.frame_num:11d} | {end_time.get_timecode()} |" for i, (start_time, end_time) in enumerate(scenes) ] ), @@ -180,7 +173,7 @@ def save_images( image_extension: str, encoder_param: int, filename: str, - output: ty.Optional[str], + output: str | None, show_progress: bool, scale: int, height: int, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 1bf8dad0..65171063 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -33,7 +33,7 @@ PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] -LogMessage = ty.Tuple[int, str] +LogMessage = tuple[int, str] class OptionParseFailure(Exception): @@ -75,13 +75,13 @@ class TimecodeValue(ValidatedValue): Stores value in original representation.""" - def __init__(self, value: ty.Union[int, float, str]): + def __init__(self, value: int | float | str): # Ensure value is a valid timecode. FrameTimecode(timecode=value, fps=100.0) self._value = value @property - def value(self) -> ty.Union[int, float, str]: + def value(self) -> int | float | str: return self._value @staticmethod @@ -99,9 +99,9 @@ class RangeValue(ValidatedValue): def __init__( self, - value: ty.Union[int, float], - min_val: ty.Union[int, float], - max_val: ty.Union[int, float], + value: int | float, + min_val: int | float, + max_val: int | float, ): if value < min_val or value > max_val: # min and max are inclusive. @@ -111,16 +111,16 @@ def __init__( self._max_val = max_val @property - def value(self) -> ty.Union[int, float]: + def value(self) -> int | float: return self._value @property - def min_val(self) -> ty.Union[int, float]: + def min_val(self) -> int | float: """Minimum value of the range.""" return self._min_val @property - def max_val(self) -> ty.Union[int, float]: + def max_val(self) -> int | float: """Maximum value of the range.""" return self._max_val @@ -134,7 +134,7 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": ) except ValueError as ex: raise OptionParseFailure( - "Value must be between %s and %s." % (default.min_val, default.max_val) + f"Value must be between {default.min_val} and {default.max_val}." ) from ex @@ -144,7 +144,7 @@ class CropValue(ValidatedValue): _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" - def __init__(self, value: ty.Optional[ty.Union[str, ty.Tuple[int, int, int, int]]] = None): + def __init__(self, value: str | tuple[int, int, int, int] | None = None): if isinstance(value, CropValue) or value is None: self._crop = value else: @@ -165,11 +165,12 @@ def __init__(self, value: ty.Optional[ty.Union[str, ty.Tuple[int, int, int, int] self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) @property - def value(self) -> ty.Tuple[int, int, int, int]: + def value(self) -> tuple[int, int, int, int]: return self._crop def __str__(self) -> str: - return "[%d, %d], [%d, %d]" % self.value + x0, y0, x1, y1 = self.value + return f"[{x0}, {y0}], [{x1}, {y1}]" @staticmethod def from_config(config_value: str, default: "CropValue") -> "CropValue": @@ -185,7 +186,7 @@ class ScoreWeightsValue(ValidatedValue): _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" - def __init__(self, value: ty.Union[str, ContentDetector.Components]): + def __init__(self, value: str | ContentDetector.Components): if isinstance(value, ContentDetector.Components): self._value = value else: @@ -202,7 +203,7 @@ def value(self) -> ContentDetector.Components: return self._value def __str__(self) -> str: - return "%.3f, %.3f, %.3f, %.3f" % self.value + return "{:.3f}, {:.3f}, {:.3f}, {:.3f}".format(*self.value) @staticmethod def from_config(config_value: str, default: "ScoreWeightsValue") -> "ScoreWeightsValue": @@ -301,7 +302,7 @@ def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return "%.3f" % timecode.seconds + return f"{timecode.seconds:.3f}" raise RuntimeError("Unhandled format specifier.") @@ -314,8 +315,8 @@ class FcpFormat(Enum): """Final Cut Pro 7 XML Format""" -ConfigValue = ty.Union[bool, int, float, str] -ConfigDict = ty.Dict[str, ty.Dict[str, ConfigValue]] +ConfigValue = bool | int | float | str +ConfigDict = dict[str, dict[str, ConfigValue]] _CONFIG_FILE_NAME: ty.AnyStr = "scenedetect.cfg" _CONFIG_FILE_DIR: ty.AnyStr = user_config_dir("PySceneDetect", False) @@ -454,7 +455,7 @@ class FcpFormat(Enum): The types of these values are used when decoding the configuration file. Valid choices for certain string options are stored in `CHOICE_MAP`.""" -CHOICE_MAP: ty.Dict[str, ty.Dict[str, ty.List[str]]] = { +CHOICE_MAP: dict[str, dict[str, list[str]]] = { "backend-pyav": { "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], }, @@ -501,14 +502,14 @@ class FcpFormat(Enum): of a set to preserve order when generating error contexts. Values are case-insensitive, and must be in lowercase in this map.""" -DEPRECATED_COMMANDS: ty.Dict[str, str] = {"export-html": "save-html"} +DEPRECATED_COMMANDS: dict[str, str] = {"export-html": "save-html"} """Deprecated config file sections that have a 1:1 mapping to a new replacement.""" -def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessage]]: +def _validate_structure(parser: ConfigParser) -> tuple[bool, list[LogMessage]]: """Validates the layout of the section/option mapping. Returns a bool indicating if validation was successful, and a list of log messages for the init log.""" - logs: ty.List[LogMessage] = [] + logs: list[LogMessage] = [] success = True all_sections = set(parser.sections()) for section in all_sections: @@ -549,7 +550,7 @@ def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessa return (success, logs) -def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty.List[LogMessage]]: +def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMessage]]: """Process the given configuration into a key-value mapping. Returns a tuple of the config dict itself (or None on failure), and a list of log messages during parsing.""" (success, logs) = _validate_structure(parser) @@ -594,8 +595,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option %s': %s. Must be one of: %s." - % ( + "Invalid value for [{}] option {}': {}. Must be one of: {}.".format( command, option, parser.get(command, option), @@ -612,8 +612,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option '%s': %s is not a valid %s." - % (command, option, parser.get(command, option), value_type), + f"Invalid value for [{command}] option '{option}': {parser.get(command, option)} is not a valid {value_type}.", ) ) continue @@ -632,8 +631,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option '%s': %s\nError: %s" - % (command, option, config_value, ex.error), + f"Invalid value for [{command}] option '{option}': {config_value}\nError: {ex.error}", ) ) continue @@ -648,8 +646,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option '%s': %s. Must be one of: %s." - % ( + "Invalid value for [{}] option '{}': {}. Must be one of: {}.".format( command, option, parser.get(command, option), @@ -669,16 +666,16 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" - def __init__(self, init_log: ty.Tuple[int, str], reason: ty.Optional[Exception] = None): + def __init__(self, init_log: tuple[int, str], reason: Exception | None = None): super().__init__() self.init_log = init_log self.reason = reason class ConfigRegistry: - def __init__(self, path: ty.Optional[str] = None, throw_exception: bool = True): + def __init__(self, path: str | None = None, throw_exception: bool = True): self._config: ConfigDict = {} # Options set in the loaded config file. - self._init_log: ty.List[ty.Tuple[int, str]] = [] + self._init_log: list[tuple[int, str]] = [] self._initialized = False try: @@ -693,7 +690,7 @@ def __init__(self, path: ty.Optional[str] = None, throw_exception: bool = True): self._init_log = ex.init_log if ex.reason is not None: self._init_log += [ - (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")), + (logging.ERROR, "Error: {}".format(str(ex.reason).replace("\t", " "))), ] self._initialized = False @@ -719,9 +716,9 @@ def _log(self, log_level, log_str): def _load_from_disk(self, path=None): # Validate `path`, or if not provided, use CONFIG_FILE_PATH if it exists. if path: - self._init_log.append((logging.INFO, "Loading config from file:\n %s" % path)) + self._init_log.append((logging.INFO, f"Loading config from file:\n {path}")) if not os.path.exists(path): - self._init_log.append((logging.ERROR, "File not found: %s" % (path))) + self._init_log.append((logging.ERROR, f"File not found: {path}")) raise ConfigLoadFailure(self._init_log) else: # Gracefully handle the case where there isn't a user config file. @@ -729,7 +726,7 @@ def _load_from_disk(self, path=None): self._init_log.append((logging.DEBUG, "User config file not found.")) return path = CONFIG_FILE_PATH - self._init_log.append((logging.INFO, "Loading user config file:\n %s" % path)) + self._init_log.append((logging.INFO, f"Loading user config file:\n {path}")) # Try to load and parse the config file at `path`. config = ConfigParser() try: @@ -757,7 +754,7 @@ def get_value( self, command: str, option: str, - override: ty.Optional[ConfigValue] = None, + override: ConfigValue | None = None, ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] @@ -773,9 +770,7 @@ def get_value( return CONFIG_MAP[command][option].__class__[value.upper().strip()] return value - def get_help_string( - self, command: str, option: str, show_default: ty.Optional[bool] = None - ) -> str: + def get_help_string(self, command: str, option: str, show_default: bool | None = None) -> str: """Get a string to specify for the help text indicating the current command option value, if set, or the default. @@ -792,9 +787,9 @@ def get_help_string( value_str = "on" if self._config[command][option] else "off" else: value_str = str(self._config[command][option]) - return " [setting: %s]" % (value_str) + return f" [setting: {value_str}]" if show_default is False or ( show_default is None and is_flag and CONFIG_MAP[command][option] is False ): return "" - return " [default: %s]" % (str(CONFIG_MAP[command][option])) + return f" [default: {str(CONFIG_MAP[command][option])}]" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index b23287e3..771dc013 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -91,32 +91,32 @@ def __init__(self): self.video_stream: VideoStream = None self.load_scenes_input: str = None # load-scenes -i/--input self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name - self.start_time: ty.Optional[FrameTimecode] = None # time -s/--start - self.end_time: ty.Optional[FrameTimecode] = None # time -e/--end - self.duration: ty.Optional[FrameTimecode] = None # time -d/--duration + self.start_time: FrameTimecode | None = None # time -s/--start + self.end_time: FrameTimecode | None = None # time -e/--end + self.duration: FrameTimecode | None = None # time -d/--duration self.frame_skip: int = None # Options: self.drop_short_scenes: bool = None self.merge_last_scene: bool = None self.min_scene_len: FrameTimecode = None - self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None + self.default_detector: tuple[type[SceneDetector], dict[str, ty.Any]] = None self.output: str = None self.stats_file_path: str = None # Output Commands (e.g. split-video, save-images): # Commands to run after the detection pipeline. Stored as (callback, args) and invoked with # the results of the detection pipeline by the controller. - self.commands: ty.List[ty.Tuple[ty.Callable, ty.Dict[str, ty.Any]]] = [] + self.commands: list[tuple[ty.Callable, dict[str, ty.Any]]] = [] - def add_command(self, command: ty.Callable, command_args: ty.Dict[str, ty.Any]): + def add_command(self, command: ty.Callable, command_args: dict[str, ty.Any]): """Add `command` to the processing pipeline. Will be called after processing the input.""" if "output" in command_args and command_args["output"] is None: command_args["output"] = self.output logger.debug("Adding command: %s(%s)", command.__name__, command_args) self.commands.append((command, command_args)) - def add_detector(self, detector: ty.Type[SceneDetector], detector_args: ty.Dict[str, ty.Any]): + def add_detector(self, detector: type[SceneDetector], detector_args: dict[str, ty.Any]): """Instantiate and add `detector` to the processing pipeline.""" if self.load_scenes_input: raise click.ClickException("The load-scenes command cannot be used with detectors.") @@ -130,7 +130,7 @@ def ensure_detector(self): (detector_type, detector_args) = self.default_detector self.add_detector(detector_type, detector_args) - def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> FrameTimecode: + def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode: """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` is None it will be passed through without processing. @@ -155,21 +155,21 @@ def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> def handle_options( self, input_path: ty.AnyStr, - output: ty.Optional[ty.AnyStr], + output: ty.AnyStr | None, framerate: float, - stats_file: ty.Optional[ty.AnyStr], + stats_file: ty.AnyStr | None, frame_skip: int, min_scene_len: str, - drop_short_scenes: ty.Optional[bool], - merge_last_scene: ty.Optional[bool], - backend: ty.Optional[str], - crop: ty.Optional[ty.Tuple[int, int, int, int]], - downscale: ty.Optional[int], + drop_short_scenes: bool | None, + merge_last_scene: bool | None, + backend: str | None, + crop: tuple[int, int, int, int] | None, + downscale: int | None, quiet: bool, - logfile: ty.Optional[ty.AnyStr], - config: ty.Optional[ty.AnyStr], - stats: ty.Optional[ty.AnyStr], - verbosity: ty.Optional[str], + logfile: ty.AnyStr | None, + config: ty.AnyStr | None, + stats: ty.AnyStr | None, + verbosity: str | None, ): """Parse all global options/arguments passed to the main scenedetect command, before other sub-commands (e.g. this function processes the [options] when calling @@ -206,7 +206,9 @@ def handle_options( init_failure = True init_log += ex.init_log if ex.reason is not None: - init_log += [(logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " "))] + init_log += [ + (logging.ERROR, "Error: {}".format(str(ex.reason).replace("\t", " "))) + ] finally: # Make sure we print the version number even on any kind of init failure. logger.info("PySceneDetect %s", scenedetect.__version__) @@ -312,13 +314,13 @@ def handle_options( def get_detect_content_params( self, - threshold: ty.Optional[float] = None, + threshold: float | None = None, luma_only: bool = None, - min_scene_len: ty.Optional[str] = None, - weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, - kernel_size: ty.Optional[int] = None, - filter_mode: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + min_scene_len: str | None = None, + weights: tuple[float, float, float, float] | None = None, + kernel_size: int | None = None, + filter_mode: str | None = None, + ) -> dict[str, ty.Any]: """Get a dict containing user options to construct a ContentDetector with.""" if self.drop_short_scenes: min_scene_len = 0 @@ -350,14 +352,14 @@ def get_detect_content_params( def get_detect_adaptive_params( self, - threshold: ty.Optional[float] = None, - min_content_val: ty.Optional[float] = None, - frame_window: ty.Optional[int] = None, + threshold: float | None = None, + min_content_val: float | None = None, + frame_window: int | None = None, luma_only: bool = None, - min_scene_len: ty.Optional[str] = None, - weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, - kernel_size: ty.Optional[int] = None, - ) -> ty.Dict[str, ty.Any]: + min_scene_len: str | None = None, + weights: tuple[float, float, float, float] | None = None, + kernel_size: int | None = None, + ) -> dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" if self.drop_short_scenes: @@ -392,11 +394,11 @@ def get_detect_adaptive_params( def get_detect_threshold_params( self, - threshold: ty.Optional[float] = None, - fade_bias: ty.Optional[float] = None, + threshold: float | None = None, + fade_bias: float | None = None, add_last_scene: bool = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + min_scene_len: str | None = None, + ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" if self.drop_short_scenes: @@ -419,10 +421,10 @@ def get_detect_threshold_params( def get_detect_hist_params( self, - threshold: ty.Optional[float] = None, - bins: ty.Optional[int] = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + threshold: float | None = None, + bins: int | None = None, + min_scene_len: str | None = None, + ) -> dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" if self.drop_short_scenes: @@ -442,11 +444,11 @@ def get_detect_hist_params( def get_detect_hash_params( self, - threshold: ty.Optional[float] = None, - size: ty.Optional[int] = None, - lowpass: ty.Optional[int] = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + threshold: float | None = None, + size: int | None = None, + lowpass: int | None = None, + min_scene_len: str | None = None, + ) -> dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" if self.drop_short_scenes: @@ -471,9 +473,9 @@ def get_detect_hash_params( def _initialize_logging( self, - quiet: ty.Optional[bool] = None, - verbosity: ty.Optional[str] = None, - logfile: ty.Optional[ty.AnyStr] = None, + quiet: bool | None = None, + verbosity: str | None = None, + logfile: ty.AnyStr | None = None, ): """Setup logging based on CLI args and user configuration settings.""" if quiet is not None: @@ -505,8 +507,8 @@ def _initialize_logging( def _open_video_stream( self, input_path: ty.AnyStr, - framerate: ty.Optional[float], - backend: ty.Optional[str], + framerate: float | None, + backend: str | None, ): if "%" in input_path and backend != "opencv": raise click.BadParameter( @@ -519,7 +521,7 @@ def _open_video_stream( backend = self.config.get_value("global", "backend", backend) if backend not in AVAILABLE_BACKENDS: raise click.BadParameter( - "Specified backend %s is not available on this system!" % backend, + f"Specified backend {backend} is not available on this system!", param_hint="-b/--backend", ) @@ -566,13 +568,14 @@ def _open_video_stream( if __debug__: raise raise click.BadParameter( - "Failed to open input video%s: %s" - % (" using %s backend" % backend if backend else "", str(ex)), + "Failed to open input video{}: {}".format( + f" using {backend} backend" if backend else "", str(ex) + ), param_hint="-i/--input", ) from ex except OSError as ex: if __debug__: raise raise click.BadParameter( - "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" + f"Input error:\n\n\t{str(ex)}\n", param_hint="-i/--input" ) from None diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index be7ac8d2..621d9217 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -94,7 +94,7 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene return scene_list -def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: +def _detect(context: CliContext) -> tuple[SceneList, CutList] | None: perf_start_time = time.time() context.ensure_detector() @@ -162,7 +162,7 @@ def _save_stats(context: CliContext) -> None: logger.debug("No frame metrics updated, skipping update of the stats file.") -def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: +def _load_scenes(context: CliContext) -> tuple[SceneList, CutList]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py index 634c216f..f71c9cf5 100644 --- a/scenedetect/_thirdparty/simpletable.py +++ b/scenedetect/_thirdparty/simpletable.py @@ -173,8 +173,7 @@ def __str__(self): def __iter__(self): """Iterate through row cells""" - for cell in self.cells: - yield cell + yield from self.cells def add_cell(self, cell): """Add a SimpleTableCell object to the list of cells.""" @@ -249,8 +248,7 @@ def __str__(self): def __iter__(self): """Iterate through table rows""" - for row in self.rows: - yield row + yield from self.rows def add_row(self, row): """Add a SimpleTableRow object to the list of rows.""" @@ -298,8 +296,7 @@ def __str__(self): def __iter__(self): """Iterate through tables""" - for table in self.tables: - yield table + yield from self.tables def save(self, filename): """Save HTML page to a file using the proper encoding""" diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 6f5d9086..15a53f16 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -100,7 +100,7 @@ # TODO: Lazy-loading backends would improve startup performance. However, this requires removing # some of the re-exported types above from the public API. -AVAILABLE_BACKENDS: ty.Dict[str, ty.Type] = { +AVAILABLE_BACKENDS: dict[str, type] = { backend.BACKEND_NAME: backend for backend in filter( None, diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 8701c47d..efb735f8 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -41,7 +41,7 @@ def _retry_on_oserror(op_name: str, fn: ty.Callable): """Run ``fn``, retrying up to ``_FFMPEG_RETRY_COUNT`` times on ``OSError``.""" - last_exc: ty.Optional[OSError] = None + last_exc: OSError | None = None for attempt in range(_FFMPEG_RETRY_COUNT + 1): try: return fn() @@ -63,9 +63,7 @@ def _retry_on_oserror(op_name: str, fn: ty.Callable): class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__( - self, path: ty.AnyStr, framerate: ty.Optional[float] = None, print_infos: bool = False - ): + def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: bool = False): """Open a video or device. Arguments: @@ -97,8 +95,8 @@ def __init__( # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame # will always be the current frame, and self._reader.lastread will be the next. - self._last_frame: ty.Union[bool, np.ndarray] = False - self._last_frame_rgb: ty.Optional[np.ndarray] = None + self._last_frame: bool | np.ndarray = False + self._last_frame_rgb: np.ndarray | None = None # Older versions don't track the video position when calling read_frame so we need # to keep track of the current frame number. self._frame_number = 0 @@ -119,7 +117,7 @@ def frame_rate(self) -> Fraction: return framerate_to_fraction(self._reader.fps) @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: """Video path.""" return self._path @@ -134,12 +132,12 @@ def is_seekable(self) -> bool: return True @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return tuple(self._reader.infos["video_size"]) @property - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" assert isinstance(self._reader.infos["duration"], float) return self.base_timecode + self._reader.infos["duration"] @@ -191,7 +189,7 @@ def frame_number(self) -> int: """ return self._frame_number - def seek(self, target: ty.Union[FrameTimecode, float, int]): + def seek(self, target: FrameTimecode | float | int): """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -248,7 +246,7 @@ def reset(self, print_infos=False): "reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) ) - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: if not hasattr(self._reader, "lastread") or self._eof: return False has_last_read = hasattr(self._reader, "last_read") diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 5401119a..e3049759 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -66,9 +66,9 @@ class VideoStreamCv2(VideoStream): def __init__( self, path: ty.AnyStr = None, - framerate: ty.Optional[float] = None, + framerate: float | None = None, max_decode_attempts: int = 5, - path_or_device: ty.Union[bytes, str, int] = None, + path_or_device: bytes | str | int = None, ): """Open a video file, image sequence, or network stream. @@ -100,7 +100,7 @@ def __init__( if path is None: raise ValueError("Path must be specified!") if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") if max_decode_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") @@ -108,10 +108,10 @@ def __init__( self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: - self._cap: ty.Optional[cv2.VideoCapture] = ( + self._cap: cv2.VideoCapture | None = ( None # Reference to underlying cv2.VideoCapture object. ) - self._frame_rate: ty.Optional[Fraction] = None + self._frame_rate: Fraction | None = None # VideoCapture state self._has_grabbed = False @@ -149,10 +149,10 @@ def frame_rate(self) -> Fraction: return self._frame_rate @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: if self._is_device: assert isinstance(self._path_or_device, (int)) - return "Device %d" % self._path_or_device + return f"Device {self._path_or_device}" assert isinstance(self._path_or_device, (bytes, str)) return self._path_or_device @@ -175,7 +175,7 @@ def is_seekable(self) -> bool: return not self._is_device @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -183,7 +183,7 @@ def frame_size(self) -> ty.Tuple[int, int]: ) @property - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None @@ -224,7 +224,7 @@ def position_ms(self) -> float: def frame_number(self) -> int: return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) - def seek(self, target: ty.Union[FrameTimecode, float, int]): + def seek(self, target: FrameTimecode | float | int): if self._is_device: raise SeekError("Cannot seek if input is a device!") if target < 0: @@ -262,7 +262,7 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() @@ -293,7 +293,7 @@ def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: # Private Methods # - def _open_capture(self, framerate: ty.Optional[float] = None): + def _open_capture(self, framerate: float | None = None): """Opens capture referenced by this object and resets internal state.""" if self._is_device and self._path_or_device < 0: raise ValueError("Invalid/negative device ID specified.") @@ -346,7 +346,7 @@ class VideoCaptureAdapter(VideoStream): def __init__( self, cap: cv2.VideoCapture, - framerate: ty.Optional[float] = None, + framerate: float | None = None, max_read_attempts: int = 5, ): """Create from an existing OpenCV VideoCapture object. Used for webcams, live streams, @@ -368,7 +368,7 @@ def __init__( super().__init__() if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") if max_read_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") if not cap.isOpened(): @@ -430,7 +430,7 @@ def is_seekable(self) -> bool: return False @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Reported size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -438,7 +438,7 @@ def frame_size(self) -> ty.Tuple[int, int]: ) @property - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: @@ -471,7 +471,7 @@ def position_ms(self) -> float: def frame_number(self) -> int: return self._num_frames - def seek(self, target: ty.Union[FrameTimecode, float, int]): + def seek(self, target: FrameTimecode | float | int): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") @@ -479,7 +479,7 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index a1ade9b4..72ce952b 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -35,10 +35,10 @@ class VideoStreamAv(VideoStream): # calculates the end time. def __init__( self, - path_or_io: ty.Union[ty.AnyStr, ty.BinaryIO], - framerate: ty.Optional[ty.Union[float, Fraction]] = None, - name: ty.Optional[str] = None, - threading_mode: ty.Optional[str] = None, + path_or_io: ty.AnyStr | ty.BinaryIO, + framerate: float | Fraction | None = None, + name: str | None = None, + threading_mode: str | None = None, suppress_output: bool = False, ): """Open a video by path. @@ -76,12 +76,12 @@ def __init__( # Ensure specified framerate is valid if set. if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") self._name = "" if name is None else name self._path = "" - self._frame: ty.Optional[av.VideoFrame] = None - self._decoder: ty.Optional[ty.Generator] = None + self._frame: av.VideoFrame | None = None + self._decoder: ty.Generator | None = None self._decode_count: int = 0 self._reopened = True @@ -90,7 +90,7 @@ def __init__( threading_mode = av.codec.context.ThreadType[threading_mode.upper()] except KeyError as _: raise ValueError( - "Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES + f"Invalid threading mode! Must be one of: {VALID_THREAD_MODES}" ) from None if not suppress_output: @@ -149,12 +149,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: """Video path.""" return self._path @property - def name(self) -> ty.Union[bytes, str]: + def name(self) -> bytes | str: """Name of the video, without extension.""" return self._name @@ -164,7 +164,7 @@ def is_seekable(self) -> bool: return self._io.seekable() @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return (self._codec_context.width, self._codec_context.height) @@ -233,7 +233,7 @@ def aspect_ratio(self) -> float: frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio - def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: + def seek(self, target: FrameTimecode | float | int) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -282,7 +282,7 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: # Reuse a persistent decoder generator so the codec's internal frame buffer (used for # B-frame reordering) is never flushed prematurely. Creating a new generator each call # caused the last buffered frame to be lost at EOF. diff --git a/scenedetect/common.py b/scenedetect/common.py index 545ef7df..71b51c8d 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -74,18 +74,18 @@ ## Type Aliases ## -SceneList = ty.List[ty.Tuple["FrameTimecode", "FrameTimecode"]] +SceneList = list[tuple["FrameTimecode", "FrameTimecode"]] """Type hint for a list of scenes in the form (start time, end time).""" -CutList = ty.List["FrameTimecode"] +CutList = list["FrameTimecode"] """Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" -CropRegion = ty.Tuple[int, int, int, int] +CropRegion = tuple[int, int, int, int] """Type hint for rectangle of the form X0 Y0 X1 Y1 for cropping frames. Coordinates are relative to source frame without downscaling. """ -TimecodePair = ty.Tuple["FrameTimecode", "FrameTimecode"] +TimecodePair = tuple["FrameTimecode", "FrameTimecode"] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" MAX_FPS_DELTA: float = 1.0 / 1000000000.0 @@ -96,7 +96,7 @@ _MINUTES_PER_HOUR = 60.0 # Common framerates mapped from their float representation to exact rational values. -_COMMON_FRAMERATES: ty.Dict[Fraction, Fraction] = { +_COMMON_FRAMERATES: dict[Fraction, Fraction] = { Fraction(24000, 1001): Fraction(24000, 1001), # 23.976... Fraction(30000, 1001): Fraction(30000, 1001), # 29.97... Fraction(60000, 1001): Fraction(60000, 1001), # 59.94... @@ -192,7 +192,7 @@ def __init__( TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. """ - self._time: ty.Union[_FrameNumber, _Seconds, Timecode] + self._time: _FrameNumber | _Seconds | Timecode """Internal time representation.""" self._rate: Fraction = None """Rate at which time passes between frames, measured in frames/sec.""" @@ -247,7 +247,7 @@ def __init__( raise TypeError("Timecode format/type unrecognized.") @property - def frame_num(self) -> ty.Optional[int]: + def frame_num(self) -> int | None: """The frame number. For VFR video or Timecode-backed objects, this is an approximation based on the average framerate. Prefer using `pts` and `time_base` for precise timing.""" if isinstance(self._time, Timecode): @@ -261,7 +261,7 @@ def frame_num(self) -> ty.Optional[int]: return self._time.value @property - def framerate(self) -> ty.Optional[float]: + def framerate(self) -> float | None: """The framerate to use for distance between frames and to calculate frame numbers. For a VFR video, this may just be the average framerate. Returns None if framerate is unknown (e.g. when working with pure Timecode representations).""" @@ -397,12 +397,12 @@ def get_timecode( mins = 0 hrs += 1 # We have to extend the precision by 1 here, since `format` will round up. - msec = format(secs, ".%df" % (precision + 1)) if precision else "" + msec = format(secs, f".{precision + 1}f") if precision else "" # Need to include decimal place in `msec_str`. msec_str = msec[-(2 + precision) : -1] secs_str = f"{int(secs):02d}{msec_str}" # Return hours, minutes, and seconds as a formatted timecode string. - return "%02d:%02d:%s" % (hrs, mins, secs_str) + return f"{hrs:02d}:{mins:02d}:{secs_str}" def _seconds_to_frames(self, seconds: float) -> int: """Convert `seconds` to the nearest number of frames using the current framerate. @@ -411,7 +411,7 @@ def _seconds_to_frames(self, seconds: float) -> int: """ return round(seconds * self._rate) - def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: + def _parse_timecode_number(self, timecode: int | float) -> int: """Parse a timecode number, storing it as the exact number of frames. Can be passed as frame number (int), seconds (float) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 8afd0f71..2b581e3a 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -42,14 +42,14 @@ class SceneDetector(ABC): """ def __init__(self): - self._stats_manager: ty.Optional[StatsManager] = None + self._stats_manager: StatsManager | None = None # Required Methods @abstractmethod def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Process the next frame. `timecode` is assumed to be sequential. Args: @@ -62,7 +62,7 @@ def process_frame( # Optional Methods - def post_process(self, timecode: int) -> ty.List[FrameTimecode]: + def post_process(self, timecode: int) -> list[FrameTimecode]: """Called after there are no more frames to process. Args: @@ -82,7 +82,7 @@ def event_buffer_length(self) -> int: # Frame Stats/Metrics @property - def stats_manager(self) -> ty.Optional[StatsManager]: + def stats_manager(self) -> StatsManager | None: """Optional :class:`StatsManager ` to use for storing frame metrics. When this detector is added to a parent :class:`SceneManager `, then this is set to the @@ -91,10 +91,10 @@ def stats_manager(self) -> ty.Optional[StatsManager]: return self._stats_manager @stats_manager.setter - def stats_manager(self, value: ty.Optional[StatsManager]): + def stats_manager(self, value: StatsManager | None): self._stats_manager = value - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: """Returns a list of all metric names/keys used by this detector. Returns: @@ -115,7 +115,7 @@ class Mode(Enum): SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" - def __init__(self, mode: Mode, length: ty.Union[int, float, str]): + def __init__(self, mode: Mode, length: int | float | str): """ Arguments: mode: The mode to use when enforcing `length`. @@ -128,7 +128,7 @@ def __init__(self, mode: Mode, length: ty.Union[int, float, str]): # known. Temporal inputs (float/non-digit str) populate `_filter_secs`; integer inputs # (int/digit str) populate `_filter_length`. self._filter_length: int = 0 - self._filter_secs: ty.Optional[float] = None + self._filter_secs: float | None = None if isinstance(length, float): self._filter_secs = length elif isinstance(length, str) and not length.strip().isdigit(): @@ -155,7 +155,7 @@ def _is_disabled(self) -> bool: return self._filter_secs <= 0.0 return self._filter_length <= 0 - def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: + def filter(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTimecode]: if self._is_disabled: return [timecode] if above_threshold else [] if self._last_above is None: @@ -166,7 +166,7 @@ def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[Fram return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") - def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: assert timecode.framerate >= 0 # Compute the threshold in seconds once from the first frame's framerate. This avoids # using an incorrect average fps (e.g. OpenCV on VFR video) on subsequent frames. @@ -180,7 +180,7 @@ def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty self._last_above = timecode return [timecode] - def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: assert timecode.framerate >= 0 # Compute the threshold in seconds once from the first frame's framerate. if self._filter_secs is None: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index f1917d77..68a4115f 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -38,12 +38,12 @@ class AdaptiveDetector(ContentDetector): def __init__( self, adaptive_threshold: float = 3.0, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, window_width: int = 2, min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: ty.Optional[int] = None, + kernel_size: int | None = None, ): """ Arguments: @@ -86,21 +86,19 @@ def __init__( self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( window_width=window_width, luma_only="" if not luma_only else "_lum" ) - self._buffer: ty.List[ty.Tuple[FrameTimecode, float]] = [] + self._buffer: list[tuple[FrameTimecode, float]] = [] # NOTE: The name of last cut is different from `self._last_scene_cut` from our base class, # and serves a different purpose! - self._last_cut: ty.Optional[FrameTimecode] = None + self._last_cut: FrameTimecode | None = None @property def event_buffer_length(self) -> int: return self.window_width - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return super().get_metrics() + [self._adaptive_ratio_key] - def process_frame( - self, timecode: FrameTimecode, frame_img: np.ndarray - ) -> ty.List[FrameTimecode]: + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: super().process_frame(timecode=timecode, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 268233c3..b4060baa 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -98,16 +98,16 @@ class _FrameData: """Frame saturation map [2D 8-bit].""" lum: numpy.ndarray """Frame luma/brightness map [2D 8-bit].""" - edges: ty.Optional[numpy.ndarray] + edges: numpy.ndarray | None """Frame edge map [2D 8-bit, edges are 255, non edges 0]. Affected by `kernel_size`.""" def __init__( self, threshold: float = 27.0, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: ty.Optional[int] = None, + kernel_size: int | None = None, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): """ @@ -127,17 +127,17 @@ def __init__( """ super().__init__() self._threshold: float = threshold - self._last_above_threshold: ty.Optional[int] = None - self._last_frame: ty.Optional[ContentDetector._FrameData] = None + self._last_above_threshold: int | None = None + self._last_frame: ContentDetector._FrameData | None = None self._weights: ContentDetector.Components = weights if luma_only: self._weights = ContentDetector.LUMA_ONLY_WEIGHTS - self._kernel: ty.Optional[numpy.ndarray] = None + self._kernel: numpy.ndarray | None = None if kernel_size is not None: if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) - self._frame_score: ty.Optional[float] = None + self._frame_score: float | None = None # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling # `min_scene_len` which should be specified in seconds, not frames. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) @@ -190,7 +190,7 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Process the next frame. `frame_num` is assumed to be sequential. Args: diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index af0994d1..16e9b543 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -51,9 +51,9 @@ def __init__( threshold: float = 0.395, size: int = 16, lowpass: int = 2, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, ): - super(HashDetector, self).__init__() + super().__init__() self._threshold = threshold self._min_scene_len = min_scene_len self._size = size @@ -69,7 +69,7 @@ def get_metrics(self): def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference frame to frame.""" diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 8502e1e5..a450b089 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -34,7 +34,7 @@ def __init__( self, threshold: float = 0.05, bins: int = 256, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, ): """ Arguments: @@ -57,7 +57,7 @@ def __init__( self._last_cut = None self._metric_key = f"hist_diff [bins={self._bins}]" - def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> list[int]: """Computes the histogram of the luma channel of the frame image and compares it with the histogram of the luma channel of the previous frame. If the difference between the histograms exceeds the threshold, a scene cut is detected. @@ -166,5 +166,5 @@ def calculate_histogram( return hist - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return [self._metric_key] diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index edb63024..2fa9114b 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -48,7 +48,7 @@ class Method(Enum): def __init__( self, threshold: float = 12, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, fade_bias: float = 0.0, add_final_scene: bool = False, method: Method = Method.FLOOR, @@ -95,12 +95,12 @@ def __init__( self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] self._time_base = None - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return self._metric_keys def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Process the next frame. Args: @@ -114,7 +114,7 @@ def process_frame( if self.last_scene_cut is None: self.last_scene_cut = timecode - cuts: ty.List[FrameTimecode] = [] + cuts: list[FrameTimecode] = [] # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on @@ -162,7 +162,7 @@ def process_frame( self.processed_frame = True return cuts - def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out. Only writes the scene cut if add_final_scene is true, and the last fade @@ -174,7 +174,7 @@ def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cuts: ty.List[FrameTimecode] = [] + cuts: list[FrameTimecode] = [] if ( self.last_fade["type"] == "out" and self.add_final_scene diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index c559938e..020e5734 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -52,9 +52,9 @@ def push(self, ys: np.ndarray, ts: np.ndarray): class Predictor: def __init__( self, - model_path: ty.Union[str, Path], + model_path: str | Path, flash_filter: FlashFilter, - onnx_providers: ty.Union[ty.List[str], None], + onnx_providers: list[str] | None, threshold, ): import onnxruntime as ort @@ -132,10 +132,10 @@ def push(self, pixels: np.ndarray, time: np.ndarray): class TransnetV2Detector(SceneDetector): def __init__( self, - model_path: ty.Union[str, Path] = "tests/resources/transnetv2.onnx", - onnx_providers: ty.Union[ty.List[str], None] = None, + model_path: str | Path = "tests/resources/transnetv2.onnx", + onnx_providers: list[str] | None = None, threshold: float = 0.5, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): super().__init__() @@ -163,9 +163,7 @@ def mk_ft(self, pts: int): t = float(pts * self.time_base) return FrameTimecode(t, fps=self._fps) - def process_frame( - self, timecode: FrameTimecode, frame_img: np.ndarray - ) -> ty.List[FrameTimecode]: + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: """Process the next frame.""" self.time_base = timecode.time_base @@ -189,7 +187,7 @@ def process_frame( else: return [] - def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out.""" cuts = [] diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index b913933f..52df30b6 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -57,7 +57,7 @@ def write_scene_list( output_csv_file: ty.TextIO, scene_list: SceneList, include_cut_list: bool = True, - cut_list: ty.Optional[CutList] = None, + cut_list: CutList | None = None, col_separator: str = ",", row_separator: str = "\n", ): @@ -103,16 +103,16 @@ def write_scene_list( duration = end - start csv_writer.writerow( [ - "%d" % (i + 1), - "%d" % (start.frame_num + 1), + f"{i + 1:d}", + f"{start.frame_num + 1:d}", start.get_timecode(), - "%.3f" % start.seconds, - "%d" % end.frame_num, + f"{start.seconds:.3f}", + f"{end.frame_num:d}", end.get_timecode(), - "%.3f" % end.seconds, - "%d" % duration.frame_num, + f"{end.seconds:.3f}", + f"{duration.frame_num:d}", duration.get_timecode(), - "%.3f" % duration.seconds, + f"{duration.seconds:.3f}", ] ) @@ -120,12 +120,12 @@ def write_scene_list( def write_scene_list_html( output_html_filename: str, scene_list: SceneList, - cut_list: ty.Optional[CutList] = None, + cut_list: CutList | None = None, css: str = None, css_class: str = "mytable", - image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]] = None, - image_width: ty.Optional[int] = None, - image_height: ty.Optional[int] = None, + image_filenames: dict[int, list[str]] | None = None, + image_width: int | None = None, + image_height: int | None = None, ): """Writes the given list of scenes to an output file handle in html format. @@ -209,16 +209,16 @@ def write_scene_list_html( row = SimpleTableRow( [ - "%d" % (i + 1), - "%d" % (start.frame_num + 1), + f"{i + 1:d}", + f"{start.frame_num + 1:d}", start.get_timecode(), - "%.3f" % start.seconds, - "%d" % end.frame_num, + f"{start.seconds:.3f}", + f"{end.frame_num:d}", end.get_timecode(), - "%.3f" % end.seconds, - "%d" % duration.frame_num, + f"{end.seconds:.3f}", + f"{duration.frame_num:d}", duration.get_timecode(), - "%.3f" % duration.seconds, + f"{duration.seconds:.3f}", ] ) @@ -252,7 +252,7 @@ def _edl_timecode(timecode: FrameTimecode) -> str: def write_scene_list_edl( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, title: str = "PySceneDetect", reel: str = "AX", @@ -298,12 +298,12 @@ def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: def write_scene_list_fcpx( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, - video_path: ty.Union[str, Path], + video_path: str | Path, frame_rate: Fraction, - frame_size: ty.Tuple[int, int], - video_name: ty.Optional[str] = None, + frame_size: tuple[int, int], + video_name: str | None = None, ): """Writes the given list of scenes to `output_path` in Final Cut Pro X XML format (FCPXML 1.9). @@ -397,13 +397,13 @@ def write_scene_list_fcpx( def write_scene_list_fcp7( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, - video_path: ty.Union[str, Path], + video_path: str | Path, frame_rate: Fraction, - frame_size: ty.Tuple[int, int], - video_name: ty.Optional[str] = None, - source_duration: ty.Optional[FrameTimecode] = None, + frame_size: tuple[int, int], + video_name: str | None = None, + source_duration: FrameTimecode | None = None, ): """Writes the given list of scenes to `output_path` in Final Cut Pro 7 XML (xmeml) format. @@ -517,11 +517,11 @@ def write_scene_list_fcp7( # fractional timecodes, we should export the framerate as a rational number instead. # https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190 def write_scene_list_otio( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, - video_path: ty.Union[str, Path], + video_path: str | Path, frame_rate: Fraction, - name: ty.Optional[str] = None, + name: str | None = None, audio: bool = True, ): """Writes the given list of scenes to `output_path` as an OTIO Timeline.1 JSON document. diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 842e1460..f3de6993 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -37,8 +37,8 @@ def _generate_timecode_list( scene_list: SceneList, num_images: int, - frame_margin: ty.Union[int, float, str], -) -> ty.List[ty.List[FrameTimecode]]: + frame_margin: int | float | str, +) -> list[list[FrameTimecode]]: """Generate per-scene image timecodes using PTS-accurate seconds-based timing. `frame_margin` accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``). @@ -72,9 +72,9 @@ def _generate_timecode_list( def _scale_image( image: np.ndarray, aspect_ratio: float, - height: ty.Optional[int], - width: ty.Optional[int], - scale: ty.Optional[float], + height: int | None, + width: int | None, + scale: float | None, interpolation: Interpolation, ) -> np.ndarray: # TODO: Combine this resize with the ones below. @@ -104,13 +104,13 @@ class _ImageExtractor: def __init__( self, num_images: int = 3, - frame_margin: ty.Union[int, float, str] = 1, + frame_margin: int | float | str = 1, image_extension: str = "jpg", - imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, + imwrite_param: dict[str, int | None] = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, + scale: float | None = None, + height: int | None = None, + width: int | None = None, interpolation: Interpolation = Interpolation.CUBIC, ): """Multi-threaded implementation of save-images functionality. Uses background threads to @@ -159,9 +159,9 @@ def run( self, video: VideoStream, scene_list: SceneList, - output_dir: ty.Optional[str] = None, + output_dir: str | None = None, show_progress=False, - ) -> ty.Dict[int, ty.List[str]]: + ) -> dict[int, list[str]]: """Run image extraction on `video` using the current parameters. Thread-safe. Arguments: @@ -192,7 +192,7 @@ def run( image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d" def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode): - return "%s.%s" % ( + return "{}.{}".format( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (scene_number + 1), @@ -325,7 +325,7 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): if progress_bar is not None: progress_bar.update(1) - def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.List[FrameTimecode]]: + def generate_timecode_list(self, scene_list: SceneList) -> list[list[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config parameters. @@ -347,18 +347,18 @@ def save_images( scene_list: SceneList, video: VideoStream, num_images: int = 3, - frame_margin: ty.Union[int, float, str] = 1, + frame_margin: int | float | str = 1, image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: ty.Optional[str] = None, - show_progress: ty.Optional[bool] = False, - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, + output_dir: str | None = None, + show_progress: bool | None = False, + scale: float | None = None, + height: int | None = None, + width: int | None = None, interpolation: Interpolation = Interpolation.CUBIC, threading: bool = True, -) -> ty.Dict[int, ty.List[str]]: +) -> dict[int, list[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -468,7 +468,7 @@ def save_images( if frame_im is not None and frame_im is not False: # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = "%s.%s" % ( + file_path = "{}.{}".format( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index a503db6b..bcee16c8 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -52,7 +52,7 @@ for details. Sorry about that! """ -_FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() +_FFMPEG_PATH: str | None = get_ffmpeg_path() """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" _DEFAULT_FFMPEG_ARGS = ( @@ -157,9 +157,9 @@ def formatter(video: VideoMetadata, scene: SceneMetadata) -> str: def split_video_mkvmerge( input_video_path: str, scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[ty.Union[str, Path]] = None, - output_file_template: ty.Optional[ty.Union[str, Path]] = "$VIDEO_NAME.mkv", - video_name: ty.Optional[str] = None, + output_dir: str | Path | None = None, + output_file_template: str | Path | None = "$VIDEO_NAME.mkv", + video_name: str | None = None, show_output: bool = False, suppress_output=None, ) -> int: @@ -217,12 +217,13 @@ def split_video_mkvmerge( "-o", str(output_path), "--split", - "parts:%s" - % ",".join( - [ - "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list - ] + "parts:{}".format( + ",".join( + [ + f"{start_time.get_timecode()}-{end_time.get_timecode()}" + for start_time, end_time in scene_list + ] + ) ), input_video_path, ] @@ -252,15 +253,15 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[Path] = None, + output_dir: Path | None = None, output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", - video_name: ty.Optional[str] = None, + video_name: str | None = None, arg_override: str = _DEFAULT_FFMPEG_ARGS, show_progress: bool = False, show_output: bool = False, suppress_output=None, hide_progress=None, - formatter: ty.Optional[PathFormatter] = None, + formatter: PathFormatter | None = None, ) -> int: """Split `input_video_path` using `ffmpeg` based on the scenes in `scene_list`. diff --git a/scenedetect/platform.py b/scenedetect/platform.py index b832e250..85bc85b4 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -76,7 +76,7 @@ def __exit__(self, type, value, traceback): # TODO: Move this into scene_manager. -def get_cv2_imwrite_params() -> ty.Dict[str, ty.Union[int, None]]: +def get_cv2_imwrite_params() -> dict[str, int | None]: """Get OpenCV imwrite Params: Returns a dict of supported image formats and their associated quality/compression parameter index, or None if that format is not supported. @@ -88,7 +88,7 @@ def get_cv2_imwrite_params() -> ty.Dict[str, ty.Union[int, None]]: current system library (e.g. {'jpg': None}). """ - def _get_cv2_param(param_name: str) -> ty.Union[int, None]: + def _get_cv2_param(param_name: str) -> int | None: if param_name.startswith("CV_"): param_name = param_name[3:] try: @@ -124,7 +124,7 @@ def get_file_name(file_path: ty.AnyStr, include_extension=True) -> ty.AnyStr: def get_and_create_path( - file_path: ty.AnyStr, output_directory: ty.Optional[ty.AnyStr] = None + file_path: ty.AnyStr, output_directory: ty.AnyStr | None = None ) -> ty.AnyStr: """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories @@ -159,7 +159,7 @@ def get_and_create_path( def init_logger( - log_level: int = logging.INFO, show_stdout: bool = False, log_file: ty.Optional[str] = None + log_level: int = logging.INFO, show_stdout: bool = False, log_file: str | None = None ): """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'. By default the logger has no handlers to suppress output. All existing log handlers are replaced @@ -204,7 +204,7 @@ class CommandTooLong(Exception): """Raised if the length of a command line argument exceeds the limit allowed on Windows.""" -def invoke_command(args: ty.List[str]) -> int: +def invoke_command(args: list[str]) -> int: """Same as calling Python's subprocess.call() method, but explicitly raises a different exception when the command length is too long. @@ -233,7 +233,7 @@ def invoke_command(args: ty.List[str]) -> int: raise -def get_ffmpeg_path() -> ty.Optional[str]: +def get_ffmpeg_path() -> str | None: """Get path to ffmpeg if available on the current system. First looks at PATH, then checks if one is available from the `imageio_ffmpeg` package. Returns None if ffmpeg couldn't be found. """ @@ -263,7 +263,7 @@ def get_ffmpeg_path() -> ty.Optional[str]: return None -def get_ffmpeg_version() -> ty.Optional[str]: +def get_ffmpeg_version() -> str | None: """Get ffmpeg version identifier, or None if ffmpeg is not found. Uses `get_ffmpeg_path()`.""" ffmpeg_path = get_ffmpeg_path() if ffmpeg_path is None: @@ -277,7 +277,7 @@ def get_ffmpeg_version() -> ty.Optional[str]: return output.splitlines()[0] -def get_mkvmerge_version() -> ty.Optional[str]: +def get_mkvmerge_version() -> str | None: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" tool_name = "mkvmerge" try: @@ -309,8 +309,8 @@ def get_system_version_info() -> str: out_lines += [ output_template.format(name, version) for name, version in ( - ("OS", "%s" % platform.platform()), - ("Python", "%s %s" % (platform.python_implementation(), platform.python_version())), + ("OS", f"{platform.platform()}"), + ("Python", f"{platform.python_implementation()} {platform.python_version()}"), ("Architecture", " + ".join(platform.architecture())), ) ] diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 8b47b32b..e68508ee 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -141,8 +141,8 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI def get_scenes_from_cuts( cut_list: CutList, - start_pos: ty.Union[int, FrameTimecode], - end_pos: ty.Union[int, FrameTimecode], + start_pos: int | FrameTimecode, + end_pos: int | FrameTimecode, ) -> SceneList: """Returns a list of tuples of start/end FrameTimecodes for each scene based on a list of detected scene cuts/breaks. @@ -194,15 +194,15 @@ class SceneManager: def __init__( self, - stats_manager: ty.Optional[StatsManager] = None, + stats_manager: StatsManager | None = None, ): """ Arguments: stats_manager: :class:`StatsManager` to bind to this `SceneManager`. Can be accessed via the `stats_manager` property of the resulting object to save to disk. """ - self._cutting_list: ty.List[FrameTimecode] = [] - self._detector_list: ty.List[SceneDetector] = [] + self._cutting_list: list[FrameTimecode] = [] + self._detector_list: list[SceneDetector] = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the # `stats_manager` argument to to `store_stats: bool=False`, or lazy-init one. @@ -210,16 +210,16 @@ def __init__( # TODO(v1.0): This class should own a VideoStream as well, instead of passing one # to the detect_scenes method. If concatenation is required, it can be implemented as # a generic VideoStream wrapper. - self._stats_manager: ty.Optional[StatsManager] = stats_manager + self._stats_manager: StatsManager | None = stats_manager # Position of video that was first passed to detect_scenes. self._start_pos: FrameTimecode = None # Position of video on the last frame processed by detect_scenes. self._last_pos: FrameTimecode = None # Size of the decoded frames. - self._frame_size: ty.Tuple[int, int] = None + self._frame_size: tuple[int, int] = None self._frame_size_errors: int = 0 - self._base_timecode: ty.Optional[FrameTimecode] = None + self._base_timecode: FrameTimecode | None = None self._downscale: int = 1 self._auto_downscale: bool = True # Interpolation method to use when downscaling. Defaults to linear interpolation @@ -231,7 +231,7 @@ def __init__( self._exception_info = None self._stop = threading.Event() - self._frame_buffer: ty.List[ty.Tuple[FrameTimecode, np.ndarray]] = [] + self._frame_buffer: list[tuple[FrameTimecode, np.ndarray]] = [] self._frame_buffer_size = 0 self._crop = None @@ -245,12 +245,12 @@ def interpolation(self, value: Interpolation): self._interpolation = value @property - def stats_manager(self) -> ty.Optional[StatsManager]: + def stats_manager(self) -> StatsManager | None: """Getter for the StatsManager associated with this SceneManager, if any.""" return self._stats_manager @property - def crop(self) -> ty.Optional[CropRegion]: + def crop(self) -> CropRegion | None: """Portion of the frame to crop. Tuple of 4 ints in the form (X0, Y0, X1, Y1) where X0, Y0 describes one point and X1, Y1 is another which describe a rectangle inside of the frame. Coordinates start from 0 and are inclusive. For example, with a 100x100 pixel video, @@ -373,7 +373,7 @@ def get_scene_list(self, start_in_scene: bool = False) -> SceneList: scene_list = [] return sorted(scene_list) - def _get_cutting_list(self) -> ty.List[FrameTimecode]: + def _get_cutting_list(self) -> list[FrameTimecode]: """Return a sorted list of unique frame numbers of any detected scene cuts.""" if not self._cutting_list: return [] @@ -384,7 +384,7 @@ def _process_frame( self, position: FrameTimecode, frame_im: np.ndarray, - callback: ty.Optional[ty.Callable[[np.ndarray, FrameTimecode], None]] = None, + callback: ty.Callable[[np.ndarray, FrameTimecode], None] | None = None, ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" @@ -419,12 +419,12 @@ def stop(self) -> None: def detect_scenes( self, video: VideoStream = None, - duration: ty.Optional[FrameTimecode] = None, - end_time: ty.Optional[FrameTimecode] = None, + duration: FrameTimecode | None = None, + end_time: FrameTimecode | None = None, frame_skip: int = 0, show_progress: bool = False, - callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, - frame_source: ty.Optional[VideoStream] = None, + callback: ty.Callable[[np.ndarray, int], None] | None = None, + frame_source: VideoStream | None = None, ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the number of frames processed. Results can be obtained by calling :meth:`get_scene_list` or diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 61e67970..8c2ea71c 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -103,12 +103,10 @@ def __init__(self, base_timecode: FrameTimecode = None): """ # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) # of each frame metric key and the value it represents (usually float). - self._frame_metrics: ty.Dict[FrameTimecode, ty.Dict[str, float]] = dict() - self._metric_keys: ty.Set[str] = set() + self._frame_metrics: dict[FrameTimecode, dict[str, float]] = dict() + self._metric_keys: set[str] = set() self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: ty.Optional[FrameTimecode] = ( - base_timecode # Used for timing calculations. - ) + self._base_timecode: FrameTimecode | None = base_timecode # Used for timing calculations. @property def metric_keys(self) -> ty.Iterable[str]: @@ -120,9 +118,7 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: # TODO(https://scenedetect.com/issues/507): We should support the dictionary protocol instead # of using this bespoke interface. It would be useful for Pandas compatibility as well. - def get_metrics( - self, timecode: FrameTimecode, metric_keys: ty.Iterable[str] - ) -> ty.List[ty.Any]: + def get_metrics(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> list[ty.Any]: """Return the requested statistics/metrics for a given timecode. Returns: @@ -131,7 +127,7 @@ def get_metrics( """ return [self._get_metric(timecode, metric_key) for metric_key in metric_keys] - def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: ty.Dict[str, ty.Any]) -> None: + def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: @@ -160,7 +156,7 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: ty.Union[str, bytes, Path, ty.TextIO], + csv_file: str | bytes | Path | ty.TextIO, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. @@ -195,7 +191,7 @@ def save_to_csv( ) @staticmethod - def valid_header(row: ty.List[str]) -> bool: + def valid_header(row: list[str]) -> bool: """Check that the given CSV row is a valid header for a statsfile. Arguments: @@ -212,7 +208,7 @@ def valid_header(row: ty.List[str]) -> bool: # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. - def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optional[int]: + def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: """[DEPRECATED] DO NOT USE Load all metrics stored in a CSV file into the StatsManager instance. Will be removed in a @@ -281,7 +277,7 @@ def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optiona self._set_metric(frame_number, loaded_metrics[i], float(metric)) except ValueError: raise StatsFileCorrupt( - "Corrupted value in stats file: %s" % metric + f"Corrupted value in stats file: {metric}" ) from ValueError num_frames += 1 self._metric_keys = self._metric_keys.union(set(loaded_metrics)) @@ -291,7 +287,7 @@ def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optiona # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Optional[ty.Any]: + def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Any | None: if self._metric_exists(timecode, metric_key): return self._frame_metrics[timecode][metric_key] return None diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 18a04b2c..987ff34b 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -106,13 +106,13 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: """Video or device path.""" ... @property @abstractmethod - def name(self) -> ty.Union[bytes, str]: + def name(self) -> bytes | str: """Name of the video, without extension, or device.""" ... @@ -130,13 +130,13 @@ def frame_rate(self) -> Fraction: @property @abstractmethod - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" ... @property @abstractmethod - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" ... @@ -175,7 +175,7 @@ def frame_number(self) -> int: # @abstractmethod - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -195,7 +195,7 @@ def reset(self) -> None: ... @abstractmethod - def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: + def seek(self, target: FrameTimecode | float | int) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). diff --git a/tests/conftest.py b/tests/conftest.py index 8cef4b0e..21a43eb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,14 +45,13 @@ def check_exists(path: ty.AnyStr) -> ty.AnyStr: """ if not os.path.exists(path): raise FileNotFoundError( - """ -Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + f""" +Test video file ({path}) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset """ - % path ) return path diff --git a/tests/helpers.py b/tests/helpers.py index 4bdb6d7c..050b0620 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -20,7 +20,7 @@ from scenedetect._cli.controller import run_scenedetect -def invoke_cli(args: ty.List[str], catch_exceptions: bool = False) -> ty.Tuple[int, str]: +def invoke_cli(args: list[str], catch_exceptions: bool = False) -> tuple[int, str]: """Invoke the scenedetect CLI in-process using Click's CliRunner. Replicates the two-step execution of ``__main__.py``: diff --git a/tests/test_api.py b/tests/test_api.py index 853b7c32..4d355ab8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,7 +20,7 @@ def test_api_detect(test_video_file: str): scene_list = detect(test_video_file, ContentDetector()) for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_detect_start_end_time(test_video_file: str): @@ -31,7 +31,7 @@ def test_api_detect_start_end_time(test_video_file: str): # See test_api_timecode_types() for examples of each format. scene_list = detect(test_video_file, ContentDetector(), start_time=10.5, end_time=15.9) for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_detect_stats(test_video_file: str): @@ -51,7 +51,7 @@ def test_api_scene_manager(test_video_file: str): scene_manager.detect_scenes(video=video) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_scene_manager_start_end_time(test_video_file: str): @@ -69,7 +69,7 @@ def test_api_scene_manager_start_end_time(test_video_file: str): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_timecode_types(): @@ -100,7 +100,7 @@ def test_api_stats_manager(test_video_file: str): scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. - filename = "%s.stats.csv" % test_video_file + filename = f"{test_video_file}.stats.csv" scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -112,7 +112,7 @@ def test_api_scene_manager_callback(test_video_file: str): # Callback to invoke on the first frame of every new scene detection. def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print("New scene found at frame %d." % frame_num) + print(f"New scene found at frame {frame_num}.") video = open_video(test_video_file) scene_manager = SceneManager() @@ -131,7 +131,7 @@ def test_api_device_callback(test_video_file: str): # Callback to invoke on the first frame of every new scene detection. def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print("New scene found at frame %d." % frame_num) + print(f"New scene found at frame {frame_num}.") # We open a file just for test purposes, but we can also use a device or pipe here. cap = cv2.VideoCapture(test_video_file) diff --git a/tests/test_cli.py b/tests/test_cli.py index 78841978..740bce51 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,8 +67,8 @@ def invoke_scenedetect( args: str = "", - output_dir: ty.Optional[str] = None, - config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, + output_dir: str | None = None, + config_file: str | None = DEFAULT_CONFIG_FILE, **kwargs, ): """Invokes the scenedetect CLI with the specified arguments and returns the exit code. @@ -98,9 +98,9 @@ def invoke_scenedetect( value_dict.update(**kwargs) command = SCENEDETECT_CMD if output_dir: - command += " -o %s" % output_dir + command += f" -o {output_dir}" if config_file: - command += " -c %s" % config_file + command += f" -c {config_file}" command += " " + args.format(**value_dict) return subprocess.call(command.strip().split(" ")) @@ -512,8 +512,9 @@ def test_cli_save_images_path_handling(tmp_path: Path): """Test `save-images` ability to handle UTF-8 paths.""" assert ( invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images -f %s" - % ("電腦檔案-$SCENE_NUMBER-$IMAGE_NUMBER"), + "-i {{VIDEO}} -s {{STATS}} time {{TIME}} {{DETECTOR}} save-images -f {}".format( + "電腦檔案-$SCENE_NUMBER-$IMAGE_NUMBER" + ), output_dir=tmp_path, ) == 0 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 8db13213..26f8d6af 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -32,14 +32,14 @@ ThresholdDetector, ) -FAST_CUT_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( +FAST_CUT_DETECTORS: tuple[type[SceneDetector]] = ( AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ) -ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) +ALL_DETECTORS: tuple[type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) # TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively # consistent frame scores regardless of resolution. This will ensure that threshold values will hold @@ -57,14 +57,13 @@ def get_absolute_path(relative_path: str) -> str: abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): raise FileNotFoundError( - """ -Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + f""" +Test video file ({relative_path}) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset """ - % relative_path ) return abs_path @@ -81,7 +80,7 @@ class TestCase: """Start time as frames.""" end_time: int """End time as frames.""" - scene_boundaries: ty.List[int] + scene_boundaries: list[int] """Scene boundaries.""" def detect(self): @@ -107,7 +106,7 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], ), - id="%s/default" % detector_type.__name__, + id=f"{detector_type.__name__}/default", ) for detector_type in FAST_CUT_DETECTORS ] @@ -121,7 +120,7 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1260, 1334, 1365], ), - id="%s/m=30" % detector_type.__name__, + id=f"{detector_type.__name__}/m=30", ) for detector_type in FAST_CUT_DETECTORS ] diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index b1e144d2..6c9b5817 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -89,7 +89,7 @@ class FakeCallback: """Fake callback used for testing. Tracks the frame numbers the callback was invoked with.""" def __init__(self): - self.scene_list: ty.List[int] = [] + self.scene_list: list[int] = [] def get_callback_lambda(self): """For testing using a lambda..""" diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 5b0ad57f..249a8cda 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -31,7 +31,7 @@ # Entries are (start_timecode, end_timecode). All backends should agree on cut timecodes since # CAP_PROP_POS_MSEC gives accurate PTS-derived timestamps. The last scene ends at the clip # boundary (end_time) which may vary slightly between backends based on frame counting. -EXPECTED_SCENES_VFR: ty.List[ty.Tuple[str, str]] = [ +EXPECTED_SCENES_VFR: list[tuple[str, str]] = [ ("00:00:00.000", "00:00:03.921"), ("00:00:03.921", "00:00:09.676"), ] @@ -40,7 +40,7 @@ # 10s of goldeneye.mp4 by dropping every 3rd frame (frames 2,5,8,...). PTS durations alternate # between 1001 and 2002 (time_base=1/24000), nominal fps=24000/1001, avg fps≈16. The last scene # ends at the clip boundary and may vary slightly between backends. -EXPECTED_SCENES_VFR_DROP3: ty.List[ty.Tuple[str, str]] = [ +EXPECTED_SCENES_VFR_DROP3: list[tuple[str, str]] = [ ("00:00:00.000", "00:00:03.754"), ("00:00:03.754", "00:00:08.759"), ] @@ -161,7 +161,7 @@ def test_vfr_csv_output(test_vfr_video: str, tmp_path): write_scene_list(f, scene_list) # Verify CSV contains valid data. - with open(csv_path, "r") as f: + with open(csv_path) as f: reader = csv.reader(f) rows = list(reader) assert len(rows) >= 3 # 2 header rows + data @@ -413,7 +413,7 @@ def test_vfr_csv_backend_conformance(test_vfr_video: str): Only the known interior scenes are compared; the last scene's end time may vary slightly between backends since it reflects the clip boundary rather than a detected cut. """ - timecodes: ty.Dict[str, ty.List[ty.Tuple[str, str]]] = {} + timecodes: dict[str, list[tuple[str, str]]] = {} for backend in ("pyav", "opencv"): video = open_video(test_vfr_video, backend=backend) sm = SceneManager() diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 115c16e4..a757c540 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -65,14 +65,13 @@ def get_absolute_path(relative_path: str) -> str: abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): raise FileNotFoundError( - """ -Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + f""" +Test video file ({relative_path}) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset """ - % relative_path ) return abs_path @@ -91,7 +90,7 @@ class VideoParameters: # TODO: Save two "golden" frames from each video on a shot boundary, and use that to validate # that seeking works correctly for all backends (as well as that no frames are dropped). -def get_test_video_params() -> ty.List[VideoParameters]: +def get_test_video_params() -> list[VideoParameters]: """Fixture for parameters of all videos.""" return [ VideoParameters( @@ -140,7 +139,7 @@ def get_test_video_params() -> ty.List[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_properties(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) @@ -153,7 +152,7 @@ def test_properties(self, vs_type: ty.Type[VideoStream], test_video: VideoParame test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_read(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" stream = vs_type(test_video.path) frame = stream.read() @@ -161,13 +160,13 @@ def test_read(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_decode(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_read_no_decode(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_time_invariants(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -190,7 +189,7 @@ def test_time_invariants(self, vs_type: ty.Type[VideoStream], test_video: VideoP 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_reset(self, vs_type: type[VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" stream = vs_type(test_video.path) # Decode some frames, then reset the VideoStream and validate the time invariants. @@ -202,7 +201,7 @@ def test_reset(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters) assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate `seek()` functionality with different offset types.""" stream = vs_type(test_video.path) @@ -248,7 +247,7 @@ def test_seek(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek_start(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" stream = vs_type(test_video.path) # Here we check similar invariants to test_time_invariants, but using seek(). @@ -283,7 +282,7 @@ def test_seek_start(self, vs_type: ty.Type[VideoStream], test_video: VideoParame assert stream.frame_number == 2 stream = vs_type(test_video.path) - def test_read_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_read_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): """Ensure calling `read()` handles the end of the video correctly.""" stream = vs_type(test_video.path) # To make the test faster, we seek to the second last frame. @@ -296,7 +295,7 @@ def test_read_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParamete else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate calling `seek()` to offset past end of video.""" stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit @@ -315,7 +314,7 @@ def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoPar else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek_invalid(self, vs_type: type[VideoStream], test_video: VideoParameters): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -331,13 +330,13 @@ def test_seek_invalid(self, vs_type: ty.Type[VideoStream], test_video: VideoPara # -def test_invalid_path(vs_type: ty.Type[VideoStream]): +def test_invalid_path(vs_type: type[VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): _ = vs_type("this_path_should_not_exist.mp4") -def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): +def test_corrupt_video(vs_type: type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. @@ -351,4 +350,4 @@ def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): # OpenCV usually fails to read the video at frame 45, but the remaining frames all seem to # decode just fine. Make sure all backends can get to 60 without reporting a failure. for frame in range(60): - assert stream.read() is not False, "Failed on frame %d!" % frame + assert stream.read() is not False, f"Failed on frame {frame}!" From 4246791becec6aa264f6f551fd226d171f2b5c97 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:30:13 -0400 Subject: [PATCH 057/163] [lint] Enable ruff SIM (simplify) rules --- pyproject.toml | 3 +-- scenedetect/_cli/config.py | 35 ++++++++++++++++++---------------- scenedetect/_cli/controller.py | 13 +++++++++---- scenedetect/backends/pyav.py | 3 ++- scenedetect/output/video.py | 21 ++++++++++---------- scenedetect/platform.py | 10 ++++++++++ scenedetect/scene_manager.py | 2 +- scenedetect/stats_manager.py | 4 +--- 8 files changed, 54 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8fa5a4b7..546ef616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,8 @@ select = [ "I", # pyupgrade "UP", - # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # flake8-simplify - #"SIM", + "SIM", ] ignore = [ # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 65171063..d63e611f 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -534,12 +534,12 @@ def _validate_structure(parser: ConfigParser) -> tuple[bool, list[LogMessage]]: ) ) continue - elif section not in CONFIG_MAP.keys(): + elif section not in CONFIG_MAP: success = False 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 option_name not in CONFIG_MAP[section]: success = False logs.append( ( @@ -640,21 +640,24 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: config_value = parser.get(command, option).replace("\n", " ").strip() - if command in CHOICE_MAP and option in CHOICE_MAP[command]: - if config_value.lower() not in CHOICE_MAP[command][option]: - success = False - logs.append( - ( - logging.ERROR, - "Invalid value for [{}] option '{}': {}. Must be one of: {}.".format( - command, - option, - parser.get(command, option), - ", ".join(choice for choice in CHOICE_MAP[command][option]), - ), - ) + if ( + command in CHOICE_MAP + and option in CHOICE_MAP[command] + and config_value.lower() not in CHOICE_MAP[command][option] + ): + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [{}] option '{}': {}. Must be one of: {}.".format( + command, + option, + parser.get(command, option), + ", ".join(choice for choice in CHOICE_MAP[command][option]), + ), ) - continue + ) + continue config[command][option] = config_value continue diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 621d9217..95ccd8a9 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -82,10 +82,15 @@ def run_scenedetect(context: CliContext): def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, # it will be merged with the previous one. - if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: - if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: - new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] + if ( + context.merge_last_scene + and context.min_scene_len is not None + and context.min_scene_len > 0 + and len(scene_list) > 1 + and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len + ): + new_last_scene = (scene_list[-2][0], scene_list[-1][1]) + scene_list = scene_list[:-2] + [new_last_scene] # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 72ce952b..59a50ada 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -100,7 +100,8 @@ def __init__( try: if isinstance(path_or_io, (str, bytes)): self._path = path_or_io - self._io = open(path_or_io, "rb") + # File handle is intentionally long-lived and tied to the VideoStream. + self._io = open(path_or_io, "rb") # noqa: SIM115 if not self._name: self._name = get_file_name(self.path, include_extension=False) else: diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index bcee16c8..9434d4de 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -32,14 +32,20 @@ import logging import math -import subprocess import time import typing as ty from dataclasses import dataclass from pathlib import Path from scenedetect.common import FrameTimecode, TimecodePair -from scenedetect.platform import CommandTooLong, Template, get_ffmpeg_path, invoke_command, tqdm +from scenedetect.platform import ( + CommandTooLong, + Template, + get_ffmpeg_path, + get_mkvmerge_path, + invoke_command, + tqdm, +) logger = logging.getLogger("pyscenedetect") @@ -52,6 +58,8 @@ for details. Sorry about that! """ +# TODO: Resolve this on first use (e.g., functools.cache on the getter) rather than at import +# time, so that importing this module doesn't spawn an ffmpeg subprocess. _FFMPEG_PATH: str | None = get_ffmpeg_path() """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" @@ -71,14 +79,7 @@ def is_mkvmerge_available() -> bool: Returns: True if `mkvmerge` can be invoked, False otherwise. """ - ret_val = None - try: - ret_val = subprocess.call(["mkvmerge", "--quiet"]) - except OSError: - return False - if ret_val is not None and ret_val != 2: - return False - return True + return get_mkvmerge_path() is not None def is_ffmpeg_available() -> bool: diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 85bc85b4..e26ce42b 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -277,6 +277,16 @@ def get_ffmpeg_version() -> str | None: return output.splitlines()[0] +def get_mkvmerge_path() -> str | None: + """Get path to mkvmerge if available on the current system by checking PATH. Returns None if + mkvmerge couldn't be found.""" + try: + subprocess.call(["mkvmerge", "--quiet"]) + return "mkvmerge" + except OSError: + return None + + def get_mkvmerge_version() -> str | None: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" tool_name = "mkvmerge" diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index e68508ee..94dad45a 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -399,7 +399,7 @@ def _process_frame( for detector in self._detector_list: cuts = detector.process_frame(position, frame_im) self._cutting_list += cuts - new_cuts = True if cuts else False + new_cuts = bool(cuts) if callback: for cut in cuts: for position, frame in self._frame_buffer: diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 8c2ea71c..3fa9a567 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -202,9 +202,7 @@ def valid_header(row: list[str]) -> bool: """ if not row or not len(row) >= 2: return False - if row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE: - return False - return True + return not (row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE) # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. From c4da726e76ec8a9837cca3778134fbffa117bd31 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:39:16 -0400 Subject: [PATCH 058/163] [lint] Enable ruff RUF rules --- pyproject.toml | 2 + scenedetect/_cli/config.py | 6 +- scenedetect/_cli/context.py | 8 +-- scenedetect/_cli/controller.py | 2 +- scenedetect/backends/opencv.py | 4 +- scenedetect/backends/pyav.py | 2 +- scenedetect/detectors/adaptive_detector.py | 2 +- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/histogram_detector.py | 2 +- scenedetect/output/__init__.py | 6 +- scenedetect/output/image.py | 2 +- scenedetect/scene_manager.py | 2 +- scenedetect/stats_manager.py | 2 +- tests/test_cli.py | 67 +++++++++++++++------ 14 files changed, 72 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 546ef616..e0702529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ select = [ "UP", # flake8-simplify "SIM", + # ruff-native checks + "RUF", ] ignore = [ # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index d63e611f..155ff3e1 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -141,7 +141,7 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": class CropValue(ValidatedValue): """Validator for crop region defined as X0 Y0 X1 Y1.""" - _IGNORE_CHARS = [",", "/", "(", ")"] + _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" def __init__(self, value: str | tuple[int, int, int, int] | None = None): @@ -183,7 +183,7 @@ def from_config(config_value: str, default: "CropValue") -> "CropValue": class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" - _IGNORE_CHARS = [",", "/", "(", ")"] + _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" def __init__(self, value: str | ContentDetector.Components): @@ -795,4 +795,4 @@ def get_help_string(self, command: str, option: str, show_default: bool | None = show_default is None and is_flag and CONFIG_MAP[command][option] is False ): return "" - return f" [default: {str(CONFIG_MAP[command][option])}]" + return f" [default: {CONFIG_MAP[command][option]!s}]" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 771dc013..22502909 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -315,7 +315,7 @@ def handle_options( def get_detect_content_params( self, threshold: float | None = None, - luma_only: bool = None, + luma_only: bool | None = None, min_scene_len: str | None = None, weights: tuple[float, float, float, float] | None = None, kernel_size: int | None = None, @@ -355,7 +355,7 @@ def get_detect_adaptive_params( threshold: float | None = None, min_content_val: float | None = None, frame_window: int | None = None, - luma_only: bool = None, + luma_only: bool | None = None, min_scene_len: str | None = None, weights: tuple[float, float, float, float] | None = None, kernel_size: int | None = None, @@ -396,7 +396,7 @@ def get_detect_threshold_params( self, threshold: float | None = None, fade_bias: float | None = None, - add_last_scene: bool = None, + add_last_scene: bool | None = None, min_scene_len: str | None = None, ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" @@ -577,5 +577,5 @@ def _open_video_stream( if __debug__: raise raise click.BadParameter( - f"Input error:\n\n\t{str(ex)}\n", param_hint="-i/--input" + f"Input error:\n\n\t{ex!s}\n", param_hint="-i/--input" ) from None diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 95ccd8a9..a1cf3084 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -90,7 +90,7 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len ): new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] + scene_list = [*scene_list[:-2], new_last_scene] # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index e3049759..2873adc6 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -65,10 +65,10 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: ty.AnyStr = None, + path: ty.AnyStr | None = None, framerate: float | None = None, max_decode_attempts: int = 5, - path_or_device: bytes | str | int = None, + path_or_device: bytes | str | int | None = None, ): """Open a video file, image sequence, or network stream. diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 59a50ada..7e36ee47 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -203,7 +203,7 @@ def frame_number(self) -> int: """Current position within stream as the frame number (CFR-equivalent). Will return 0 until the first frame is `read`. For VFR video this is an approximation - derived from PTS × framerate; use `position` for accurate PTS-based timing.""" + derived from PTS * framerate; use `position` for accurate PTS-based timing.""" if self._frame is None: return 0 return round(self._frame.time * float(self.frame_rate)) + 1 diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 68a4115f..94e45577 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -96,7 +96,7 @@ def event_buffer_length(self) -> int: return self.window_width def get_metrics(self) -> list[str]: - return super().get_metrics() + [self._adaptive_ratio_key] + return [*super().get_metrics(), self._adaptive_ratio_key] def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: super().process_frame(timecode=timecode, frame_img=frame_img) diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index b4060baa..6bdc0eab 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -85,7 +85,7 @@ class Components(ty.NamedTuple): FRAME_SCORE_KEY = "content_val" """Key in statsfile representing the final frame score after weighed by specified components.""" - METRIC_KEYS = [FRAME_SCORE_KEY, *Components._fields] + METRIC_KEYS: ty.ClassVar[list[str]] = [FRAME_SCORE_KEY, *Components._fields] """All statsfile keys this detector produces.""" @dataclass diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index a450b089..5a7a2057 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -28,7 +28,7 @@ class HistogramDetector(SceneDetector): """Compares the difference in the Y channel of YUV histograms for adjacent frames. When the difference exceeds a given threshold, a cut is detected.""" - METRIC_KEYS = ["hist_diff"] + METRIC_KEYS: ty.ClassVar[list[str]] = ["hist_diff"] def __init__( self, diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 52df30b6..d2b28b4b 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -81,7 +81,7 @@ def write_scene_list( # If required, output the cutting list as the first row (i.e. before the header row). if include_cut_list: csv_writer.writerow( - ["Timecode List:"] + cut_list + ["Timecode List:", *cut_list] if cut_list else [start.get_timecode() for start, _ in scene_list[1:]] ) @@ -121,7 +121,7 @@ def write_scene_list_html( output_html_filename: str, scene_list: SceneList, cut_list: CutList | None = None, - css: str = None, + css: str | None = None, css_class: str = "mytable", image_filenames: dict[int, list[str]] | None = None, image_width: int | None = None, @@ -293,7 +293,7 @@ def _rational_seconds(value: Fraction) -> str: def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: - """Exact seconds for `tc` as a `Fraction`, derived from PTS × time base.""" + """Exact seconds for `tc` as a `Fraction`, derived from PTS * time base.""" return Fraction(tc.pts) * tc.time_base diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index f3de6993..3d21f3c5 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -106,7 +106,7 @@ def __init__( num_images: int = 3, frame_margin: int | float | str = 1, image_extension: str = "jpg", - imwrite_param: dict[str, int | None] = None, + imwrite_param: dict[str, int | None] | None = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", scale: float | None = None, height: int | None = None, diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 94dad45a..ca9e00a6 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -608,7 +608,7 @@ def _decode_thread( self._frame_size_errors += 1 if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: logger.error( - f"ERROR: Frame at {str(video.position)} has incorrect size and " + f"ERROR: Frame at {video.position!s} has incorrect size and " f"cannot be processed: decoded size = {decoded_size}, " f"expected = {self._frame_size}. Video may be corrupt." ) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 3fa9a567..d61ac789 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -181,7 +181,7 @@ def save_to_csv( csv_writer = csv.writer(csv_file, lineterminator="\n") metric_keys = sorted(list(self._metric_keys)) - csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) + csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, *metric_keys]) frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: diff --git a/tests/test_cli.py b/tests/test_cli.py index 740bce51..f6fc82d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -167,9 +167,17 @@ def test_cli_time_end(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] - + test_case.split(), + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + *test_case.split(), + ], text=True, ) assert EXPECTED in output, test_case @@ -195,9 +203,17 @@ def test_cli_time_start(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] - + test_case.split(), + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + *test_case.split(), + ], text=True, ) assert EXPECTED in output, test_case @@ -240,9 +256,17 @@ def test_cli_time_scene_boundary(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] - + test_case.split(), + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + *test_case.split(), + ], text=True, ) assert EXPECTED in output, test_case @@ -252,8 +276,17 @@ def test_cli_time_end_of_video(): """Validate frame number/timecode alignment at the end of the video. The end timecode includes presentation time and therefore should represent the full length of the video.""" output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "detect-content", "list-scenes", "-n", "time", "-s", "1872"], + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "detect-content", + "list-scenes", + "-n", + "time", + "-s", + "1872", + ], text=True, ) assert ( @@ -667,8 +700,8 @@ def test_cli_load_scenes_output(): with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ + [ + *SCENEDETECT_CMD.split(" "), "-i", DEFAULT_VIDEO_PATH, "load-scenes", @@ -709,8 +742,8 @@ def test_cli_load_scenes_round_trip(): with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) ground_truth = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ + [ + *SCENEDETECT_CMD.split(" "), "-i", DEFAULT_VIDEO_PATH, "detect-content", @@ -726,8 +759,8 @@ def test_cli_load_scenes_round_trip(): text=True, ) loaded_first_pass = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ + [ + *SCENEDETECT_CMD.split(" "), "-i", DEFAULT_VIDEO_PATH, "load-scenes", From cc682ba2dc0f63c388a37a6b936fee8612cd0ed2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:54:25 -0400 Subject: [PATCH 059/163] [lint] Audit F401 and fix E501 --- benchmark/__main__.py | 1 - pyproject.toml | 12 +++++++++--- scenedetect/__init__.py | 3 +-- scenedetect/_cli/commands.py | 1 - scenedetect/_cli/context.py | 4 ++-- scenedetect/_cli/controller.py | 1 - scenedetect/backends/__init__.py | 2 -- scenedetect/backends/opencv.py | 3 ++- scenedetect/backends/pyav.py | 4 ++-- scenedetect/common.py | 3 ++- scenedetect/detector.py | 1 - scenedetect/detectors/adaptive_detector.py | 1 - scenedetect/detectors/hash_detector.py | 2 -- scenedetect/detectors/histogram_detector.py | 13 +++++++------ scenedetect/detectors/threshold_detector.py | 1 - scenedetect/detectors/transnet_v2.py | 5 +---- scenedetect/output/image.py | 6 ++++-- scenedetect/video_stream.py | 4 +--- tests/helpers.py | 2 -- tests/test_cli.py | 1 - tests/test_detectors.py | 1 - tests/test_scene_manager.py | 2 -- tests/test_vfr.py | 1 - tests/test_video_stream.py | 1 - 24 files changed, 31 insertions(+), 44 deletions(-) diff --git a/benchmark/__main__.py b/benchmark/__main__.py index 91169637..5925bdbc 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -1,7 +1,6 @@ import argparse import os import time -import typing as ty from tqdm import tqdm diff --git a/pyproject.toml b/pyproject.toml index e0702529..da7da0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,9 @@ select = [ "RUF", ] ignore = [ - # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. + # TODO: Audit re-exports in __init__.py files. Add `__all__` (note: must be kept in sync as + # new public symbols are added) or use redundant-alias form (`from x import Y as Y`). "F401", - # TODO: Line too long - "E501", ] fixable = ["ALL"] unfixable = [] @@ -52,6 +51,13 @@ unfixable = [] [tool.ruff.lint.per-file-ignores] # Vendored third-party code: don't rewrite/modernize upstream source. "scenedetect/_thirdparty/*" = ["UP"] +# CLI help text and validation messages are intentionally long. +"scenedetect/_cli/*" = ["E501"] +# Test data tables and golden output strings are clearer unwrapped. +"tests/*" = ["E501"] +# Doc generators and benchmark scripts mirror the CLI strings. +"docs/*" = ["E501"] +"benchmark/*" = ["E501"] [tool.pyright] include = ["scenedetect", "tests"] diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index e913d682..8db21640 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -15,14 +15,13 @@ :class:`SceneManager `. """ -import typing as ty from logging import getLogger # OpenCV is a required package, but we don't have it as an explicit dependency since we # need to support both opencv-python and opencv-python-headless. Include some additional # context with the exception if this is the case. try: - import cv2 as _ + import cv2 as _ # availability check; raise a friendlier error if missing except ModuleNotFoundError as ex: raise ModuleNotFoundError( "OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python", diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index ed4ad920..aaaef407 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -16,7 +16,6 @@ """ import logging -import typing as ty import webbrowser from string import Template diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 22502909..8e4ef849 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -25,7 +25,7 @@ CropValue, ) from scenedetect.common import MAX_FPS_DELTA, FrameTimecode -from scenedetect.detector import FlashFilter, SceneDetector +from scenedetect.detector import SceneDetector from scenedetect.detectors import ( AdaptiveDetector, ContentDetector, @@ -35,7 +35,7 @@ ) from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available from scenedetect.platform import init_logger -from scenedetect.scene_manager import Interpolation, SceneManager +from scenedetect.scene_manager import SceneManager from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index a1cf3084..77dc1eaa 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -15,7 +15,6 @@ import logging import os import time -import typing as ty import warnings from scenedetect._cli.context import CliContext diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 15a53f16..ae2059c5 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -83,8 +83,6 @@ # TODO: Future VideoStream implementations under consideration: # - Nvidia VPF: https://developer.nvidia.com/blog/vpf-hardware-accelerated-video-processing-framework-in-python/ -import typing as ty - # OpenCV must be available at minimum. from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 2873adc6..a06e03c5 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -92,7 +92,8 @@ def __init__( super().__init__() if path_or_device is not None: warnings.warn( - "The `path_or_device` argument is deprecated, use `path` or `VideoCaptureAdapter` instead.", + "The `path_or_device` argument is deprecated, use `path` or `VideoCaptureAdapter`" + " instead.", DeprecationWarning, stacklevel=2, ) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 7e36ee47..27c37d42 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -70,8 +70,8 @@ def __init__( """ self._container = None - # TODO(https://scenedetect.com/issues/258): See what `self._container.discard_corrupt = True` - # does with corrupt videos. + # TODO(https://scenedetect.com/issues/258): See what + # `self._container.discard_corrupt = True` does with corrupt videos. super().__init__() # Ensure specified framerate is valid if set. diff --git a/scenedetect/common.py b/scenedetect/common.py index 71b51c8d..73fa50cc 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -488,7 +488,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] if isinstance(other, str): return self._seconds_to_frames(self._timecode_to_seconds(other)) if isinstance(other, FrameTimecode): - # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. + # If comparing two FrameTimecodes, they must have the same framerate for frame-based + # operations. if self._rate and other._rate and not self.equal_framerate(other._rate): raise ValueError( "FrameTimecode instances require equal framerate for frame-based arithmetic." diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 2b581e3a..3f7dcf0a 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -25,7 +25,6 @@ """ import math -import typing as ty from abc import ABC, abstractmethod from enum import Enum diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 94e45577..2e634a83 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -16,7 +16,6 @@ This detector is available from the command-line as the `detect-adaptive` command. """ -import typing as ty from logging import getLogger import numpy as np diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 16e9b543..86c15f57 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -16,8 +16,6 @@ This detector is available from the command-line interface by using the `detect-hash` command. """ -import typing as ty - import cv2 import numpy diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 5a7a2057..408a8312 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -59,8 +59,8 @@ def __init__( def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> list[int]: """Computes the histogram of the luma channel of the frame image and compares it with the - histogram of the luma channel of the previous frame. If the difference between the histograms - exceeds the threshold, a scene cut is detected. + histogram of the luma channel of the previous frame. If the difference between the + histograms exceeds the threshold, a scene cut is detected. Histogram difference is computed using the correlation metric. Arguments: @@ -98,11 +98,12 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> li # Check if a new scene should be triggered # Set a correlation threshold to determine scene changes. - # The threshold value should be between -1 (perfect negative correlation, not applicable here) - # and +1 (perfect positive correlation, identical histograms). + # The threshold value should be between -1 (perfect negative correlation, not + # applicable here) and +1 (perfect positive correlation, identical histograms). # Values close to 1 indicate very similar frames, while lower values suggest changes. - # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation - # less than 0.8 between histograms will be considered significant enough to denote a scene change. + # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a + # correlation less than 0.8 between histograms will be considered significant enough to + # denote a scene change. if hist_diff <= self._threshold and ( (timecode - self._last_cut) >= self._min_scene_len ): diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 2fa9114b..1a5e4690 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -15,7 +15,6 @@ This detector is available from the command-line as the `detect-threshold` command. """ -import typing as ty import warnings from enum import Enum from logging import getLogger diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 020e5734..83c6260f 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -14,16 +14,13 @@ This detector is available from the command-line as the `detect-transnetv2` command. """ -import typing as ty -import warnings -from enum import Enum from logging import getLogger from pathlib import Path import cv2 import numpy as np -from scenedetect.common import FrameTimecode, Timecode +from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter, SceneDetector logger = getLogger("pyscenedetect") diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 3d21f3c5..0d95981b 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -173,7 +173,8 @@ def run( # Setup flags and init progress bar if available. completed = True logger.info( - f"Saving {self._num_images} images per scene [format={self._image_extension}] {output_dir if output_dir else ''} " + f"Saving {self._num_images} images per scene [format={self._image_extension}]" + f" {output_dir if output_dir else ''} " ) progress_bar = None if show_progress: @@ -440,7 +441,8 @@ def save_images( # Setup flags and init progress bar if available. completed = True logger.info( - f"Saving {num_images} images per scene [format={image_extension}] {output_dir if output_dir else ''} " + f"Saving {num_images} images per scene [format={image_extension}]" + f" {output_dir if output_dir else ''} " ) progress_bar = None if show_progress: diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 987ff34b..142df605 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -31,14 +31,12 @@ tested by adding it to the test suite in `tests/test_video_stream.py`. """ -import typing as ty from abc import ABC, abstractmethod -from dataclasses import dataclass from fractions import Fraction import numpy as np -from scenedetect.common import FrameTimecode, Timecode +from scenedetect.common import FrameTimecode class SeekError(Exception): diff --git a/tests/helpers.py b/tests/helpers.py index 050b0620..87829cb9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,8 +11,6 @@ # """Shared test helpers.""" -import typing as ty - from click.testing import CliRunner from scenedetect._cli import scenedetect as _scenedetect_cli diff --git a/tests/test_cli.py b/tests/test_cli.py index f6fc82d2..05044ff0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,7 +29,6 @@ # logic by creating a CLI context with the desired parameters. # TODO: Missing tests for --min-scene-len and --drop-short-scenes. import sys -import typing as ty from pathlib import Path import cv2 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 26f8d6af..638479b5 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -17,7 +17,6 @@ """ import os -import typing as ty from dataclasses import dataclass import pytest diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 6c9b5817..7a78813f 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -15,8 +15,6 @@ which applies SceneDetector algorithms on VideoStream backends. """ -import typing as ty - import pytest from scenedetect.backends.opencv import VideoStreamCv2 diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 249a8cda..3189ee5d 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -14,7 +14,6 @@ import csv import json import os -import typing as ty import cv2 import numpy as np diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index a757c540..e0903832 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -17,7 +17,6 @@ """ import os.path -import typing as ty from dataclasses import dataclass import numpy From 97666d4c57a2ed156f2b0f140aa913e5a3aeb6e5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 21:08:14 -0400 Subject: [PATCH 060/163] [lint] Fix CLI None-default annotations --- scenedetect/_cli/__init__.py | 3 ++ scenedetect/_cli/commands.py | 8 ++++ scenedetect/_cli/context.py | 67 ++++++++++++++++++++++------------ scenedetect/_cli/controller.py | 17 +++++++-- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1e1abc22..1ea5e81f 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -979,6 +979,7 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s assert isinstance(ctx, CliContext) logger.debug("Will load scenes from %s (start_col_name = %s)", input, start_col_name) + assert ctx.scene_manager is not None if ctx.scene_manager.get_num_detectors() > 0: raise click.ClickException("The load-scenes command cannot be used with detectors.") if ctx.load_scenes_input: @@ -1293,6 +1294,7 @@ def split_video_command( assert isinstance(ctx, CliContext) check_split_video_requirements(use_mkvmerge=mkvmerge) + assert ctx.video_stream is not None if "%" in ctx.video_stream.path or "://" in ctx.video_stream.path: error = "The split-video command is incompatible with image sequences/URLs." raise click.BadParameter(error, param_hint="split-video") @@ -1500,6 +1502,7 @@ def save_images_command( ): ctx = ctx.obj assert isinstance(ctx, CliContext) + assert ctx.video_stream is not None if "://" in ctx.video_stream.path: error_str = "\nThe save-images command is incompatible with URLs." diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index aaaef407..f87d97be 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -53,6 +53,7 @@ def save_html( show: bool, ): """Handles the `save-html` command.""" + assert context.video_stream is not None (image_filenames, output) = ( context.save_images_result if context.save_images_result is not None @@ -85,6 +86,7 @@ def save_qp( ): """Handler for the `save-qp` command.""" del scenes # We only use cuts for this handler. + assert context.video_stream is not None qp_path = get_and_create_path( Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), output, @@ -115,6 +117,7 @@ def list_scenes( row_separator: str, ): """Handles the `list-scenes` command.""" + assert context.video_stream is not None # Write scene list CSV to if required. if not no_output_file: scene_list_filename = Template(filename).safe_substitute( @@ -182,6 +185,7 @@ def save_images( ): """Handles the `save-images` command.""" del cuts # save-images only uses scenes. + assert context.video_stream is not None images = save_images_impl( scene_list=scenes, @@ -215,6 +219,7 @@ def split_video( ): """Handles the `split-video` command.""" del cuts # split-video only uses scenes. + assert context.video_stream is not None if use_mkvmerge: name_format = name_format.removesuffix("-$SCENE_NUMBER") @@ -261,6 +266,7 @@ def save_edl( ): """Handles the `save-edl` command. Outputs in CMX 3600 format.""" del cuts # We only use scene information. + assert context.video_stream is not None video_name = context.video_stream.name edl_path = get_and_create_path( Template(filename).safe_substitute(VIDEO_NAME=video_name), @@ -286,6 +292,7 @@ def save_fcp( del cuts # We only use scene information. if not scenes: return + assert context.video_stream is not None video_stream = context.video_stream video_name = str(video_stream.name) @@ -328,6 +335,7 @@ def save_otio( ): """Handles the `save-otio` command.""" del cuts # We only use scene information + assert context.video_stream is not None video_stream = context.video_stream video_name = str(video_stream.name) otio_path = get_and_create_path( diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 8e4ef849..838552ff 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -81,28 +81,28 @@ class CliContext: def __init__(self): # State: self.config: ConfigRegistry = USER_CONFIG - self.quiet_mode: bool = None - self.scene_manager: SceneManager = None - self.stats_manager: StatsManager = None + self.quiet_mode: bool | None = None + self.scene_manager: SceneManager | None = None + self.stats_manager: StatsManager | None = None self.save_images: bool = False # True if the save-images command was specified self.save_images_result: ty.Any = (None, None) # Result of save-images used by save-html # Input: - self.video_stream: VideoStream = None - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.video_stream: VideoStream | None = None + self.load_scenes_input: str | None = None # load-scenes -i/--input + self.load_scenes_column_name: str | None = None # load-scenes -c/--start-col-name self.start_time: FrameTimecode | None = None # time -s/--start self.end_time: FrameTimecode | None = None # time -e/--end self.duration: FrameTimecode | None = None # time -d/--duration - self.frame_skip: int = None + self.frame_skip: int | None = None # Options: - self.drop_short_scenes: bool = None - self.merge_last_scene: bool = None - self.min_scene_len: FrameTimecode = None - self.default_detector: tuple[type[SceneDetector], dict[str, ty.Any]] = None - self.output: str = None - self.stats_file_path: str = None + self.drop_short_scenes: bool | None = None + self.merge_last_scene: bool | None = None + self.min_scene_len: FrameTimecode | None = None + self.default_detector: tuple[type[SceneDetector], dict[str, ty.Any]] | None = None + self.output: str | None = None + self.stats_file_path: str | None = None # Output Commands (e.g. split-video, save-images): # Commands to run after the detection pipeline. Stored as (callback, args) and invoked with @@ -120,17 +120,20 @@ def add_detector(self, detector: type[SceneDetector], detector_args: dict[str, t """Instantiate and add `detector` to the processing pipeline.""" if self.load_scenes_input: raise click.ClickException("The load-scenes command cannot be used with detectors.") + assert self.scene_manager is not None logger.debug("Adding detector: %s(%s)", detector.__name__, detector_args) self.scene_manager.add_detector(detector(**detector_args)) def ensure_detector(self): """Ensures at least one detector has been instantiated, otherwise adds a default one.""" + assert self.scene_manager is not None + assert self.default_detector is not None if self.scene_manager.get_num_detectors() == 0: logger.debug("No detector specified, adding default detector.") (detector_type, detector_args) = self.default_detector self.add_detector(detector_type, detector_args) - def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode: + def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode | None: """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` is None it will be passed through without processing. @@ -142,11 +145,13 @@ def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameT try: if self.video_stream is None: raise click.ClickException("No input video (-i/--input) was specified.") + timecode: int | str if correct_pts and value.isdigit(): - value = int(value) - if value >= 1: - value -= 1 - return FrameTimecode(timecode=value, fps=self.video_stream.frame_rate) + int_value = int(value) + timecode = int_value - 1 if int_value >= 1 else int_value + else: + timecode = value + return FrameTimecode(timecode=timecode, fps=self.video_stream.frame_rate) except ValueError as ex: raise click.BadParameter( "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" @@ -297,6 +302,7 @@ def handle_options( crop = self.config.get_value("global", "crop", CropValue(crop)) if crop is not None: (min_x, min_y) = crop[0:2] + assert self.video_stream is not None frame_size = self.video_stream.frame_size if min_x >= frame_size[0] or min_y >= frame_size[1]: region = CropValue(crop) @@ -327,10 +333,13 @@ def get_detect_content_params( else: if min_scene_len is None: if self.config.is_default("detect-content", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-content", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num if weights is not None: try: @@ -367,10 +376,13 @@ def get_detect_adaptive_params( else: if min_scene_len is None: if self.config.is_default("detect-adaptive", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num if weights is not None: try: @@ -406,10 +418,13 @@ def get_detect_threshold_params( else: if min_scene_len is None: if self.config.is_default("detect-threshold", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { "add_final_scene": add_last_scene @@ -432,10 +447,13 @@ def get_detect_hist_params( else: if min_scene_len is None: if self.config.is_default("detect-hist", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num return { "bins": self.config.get_value("detect-hist", "bins", bins), "min_scene_len": min_scene_len, @@ -456,10 +474,13 @@ def get_detect_hash_params( else: if min_scene_len is None: if self.config.is_default("detect-hash", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), "min_scene_len": min_scene_len, diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 77dc1eaa..a53ea77e 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -92,7 +92,11 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene scene_list = [*scene_list[:-2], new_last_scene] # Handle --drop-short-scenes. - if context.drop_short_scenes and context.min_scene_len > 0: + if ( + context.drop_short_scenes + and context.min_scene_len is not None + and context.min_scene_len > 0 + ): scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] return scene_list @@ -100,6 +104,9 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene def _detect(context: CliContext) -> tuple[SceneList, CutList] | None: perf_start_time = time.time() + assert context.scene_manager is not None + assert context.video_stream is not None + assert context.frame_skip is not None context.ensure_detector() if context.start_time is not None: @@ -157,6 +164,7 @@ def _save_stats(context: CliContext) -> None: """Handles saving the statsfile if -s/--stats was specified.""" if not context.stats_file_path: return + assert context.stats_manager is not None if context.stats_manager.is_save_required(): path = get_and_create_path(context.stats_file_path, context.output) logger.info("Saving frame metrics to stats file: %s", path) @@ -168,8 +176,11 @@ def _save_stats(context: CliContext) -> None: def _load_scenes(context: CliContext) -> tuple[SceneList, CutList]: assert context.load_scenes_input + assert context.load_scenes_column_name is not None + assert context.video_stream is not None assert os.path.exists(context.load_scenes_input) + video_stream = context.video_stream with open(context.load_scenes_input) as input_file: file_reader = csv.reader(input_file) csv_headers = next(file_reader) @@ -184,8 +195,8 @@ def calculate_timecode(value: str) -> FrameTimecode: # Assume other columns are in seconds except frame numbers. if value.isdigit(): # Frame numbers start from index 1 in the CLI output so we correct for that. - return FrameTimecode(int(value) - 1, fps=context.video_stream.frame_rate) - return FrameTimecode(value, fps=context.video_stream.frame_rate) + return FrameTimecode(int(value) - 1, fps=video_stream.frame_rate) + return FrameTimecode(value, fps=video_stream.frame_rate) cut_list = sorted(calculate_timecode(row[col_idx]) for row in file_reader) # `SceneDetector` works on cuts, so we have to skip the first scene and place the first From 6d4c780bc4779346d5f1159e7d7b4b4045ca8a61 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 00:34:11 -0400 Subject: [PATCH 061/163] [lint] None checks for backends --- scenedetect/backends/moviepy.py | 9 ++++++--- scenedetect/backends/opencv.py | 16 ++++++++++++++-- scenedetect/backends/pyav.py | 11 +++++++++-- scenedetect/common.py | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index efb735f8..3711c133 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -102,7 +102,7 @@ def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: self._frame_number = 0 # We need to manually keep track of EOF as duration may not be accurate. self._eof = False - self._aspect_ratio: float = None + self._aspect_ratio: float | None = None # # VideoStream Methods/Properties @@ -211,11 +211,13 @@ def seek(self, target: FrameTimecode | float | int): success = False if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) + duration = self.duration + assert duration is not None try: self._last_frame = _retry_on_oserror( "seek", lambda: self._reader.get_frame(target.seconds) ) - if hasattr(self._reader, "last_read") and target >= self.duration: + if hasattr(self._reader, "last_read") and target >= duration: raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") self._frame_number = min( target.frame_num, @@ -228,7 +230,7 @@ def seek(self, target: FrameTimecode | float | int): # # We need to ensure consistency for seeking past end of video with respect to errors and # behaviour, and should probably gracefully stop at the last frame instead of throwing. - if target >= self.duration: + if target >= duration: raise SeekError("Target frame is beyond end of video!") from ex raise finally: @@ -263,5 +265,6 @@ def read(self, decode: bool = True) -> np.ndarray | bool: last_frame_valid = self._last_frame is not None and self._last_frame is not False if last_frame_valid: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) + assert self._last_frame_rgb is not None return self._last_frame_rgb return not self._eof diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index a06e03c5..90697805 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -178,6 +178,7 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" + assert self._cap is not None return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), @@ -188,11 +189,13 @@ def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None + assert self._cap is not None return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" + assert self._cap is not None return _get_aspect_ratio(self._cap) @property @@ -201,6 +204,7 @@ def timecode(self) -> Timecode: # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to # avoid frame-boundary rounding errors at common framerates like 24000/1001. + assert self._cap is not None ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) time_base = Fraction(1, 1000000) return Timecode(pts=round(ms * 1000), time_base=time_base) @@ -219,10 +223,12 @@ def position(self) -> FrameTimecode: @property def position_ms(self) -> float: + assert self._cap is not None return self._cap.get(cv2.CAP_PROP_POS_MSEC) @property def frame_number(self) -> int: + assert self._cap is not None return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) def seek(self, target: FrameTimecode | float | int): @@ -230,7 +236,9 @@ def seek(self, target: FrameTimecode | float | int): raise SeekError("Cannot seek if input is a device!") if target < 0: raise ValueError("Target seek position cannot be negative!") + assert self._cap is not None + assert self._frame_rate is not None target_secs = (self.base_timecode + target).seconds self._has_grabbed = False if target_secs > 0: @@ -260,16 +268,20 @@ def seek(self, target: FrameTimecode | float | int): def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + assert self._cap is not None + assert self._frame_rate is not None self._cap.release() - self._open_capture(self._frame_rate) + self._open_capture(float(self._frame_rate)) def read(self, decode: bool = True) -> np.ndarray | bool: + assert self._cap is not None if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() # If we failed to grab the frame, retry a few times if required. if not has_grabbed: - if self.duration > 0 and self.position < (self.duration - 1): + duration = self.duration + if duration is not None and duration > 0 and self.position < (duration - 1): for _ in range(self._max_decode_attempts): has_grabbed = self._cap.grab() if has_grabbed: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 27c37d42..067e935f 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -68,7 +68,7 @@ def __init__( VideoOpenFailure: video could not be opened (may be corrupted) ValueError: specified framerate is invalid """ - self._container = None + self._container: av.container.InputContainer | None = None # TODO(https://scenedetect.com/issues/258): See what # `self._container.discard_corrupt = True` does with corrupt videos. @@ -213,7 +213,7 @@ def rate(self) -> Fraction: return self._video_stream.guessed_rate @property - def time_base(self) -> Fraction: + def time_base(self) -> Fraction | None: if self._frame: return self._frame.time_base return None @@ -265,6 +265,7 @@ def seek(self, target: FrameTimecode | float | int) -> None: self._frame = None self._decoder = None self._decode_count = 0 + assert self._container is not None self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -274,6 +275,7 @@ def seek(self, target: FrameTimecode | float | int) -> None: def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + assert self._container is not None self._container.close() self._frame = None self._decoder = None @@ -288,9 +290,11 @@ def read(self, decode: bool = True) -> np.ndarray | bool: # B-frame reordering) is never flushed prematurely. Creating a new generator each call # caused the last buffered frame to be lost at EOF. if self._decoder is None: + assert self._container is not None self._decoder = self._container.decode(video=0) try: last_frame = self._frame + assert self._decoder is not None self._frame = next(self._decoder) self._decode_count += 1 except av.error.EOFError: @@ -300,6 +304,7 @@ def read(self, decode: bool = True) -> np.ndarray | bool: return False except StopIteration: return False + assert self._frame is not None return self._frame.to_ndarray(format="bgr24") if decode else True # @@ -309,6 +314,7 @@ def read(self, decode: bool = True) -> np.ndarray | bool: @property def _video_stream(self): """PyAV `av.video.stream.VideoStream` being used.""" + assert self._container is not None return self._container.streams.video[0] @property @@ -365,6 +371,7 @@ def _handle_eof(self): except: self._io.seek(orig_pos) raise + assert self._container is not None self._container.close() self._container = container self._decoder = None diff --git a/scenedetect/common.py b/scenedetect/common.py index 73fa50cc..a045298b 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -247,7 +247,7 @@ def __init__( raise TypeError("Timecode format/type unrecognized.") @property - def frame_num(self) -> int | None: + def frame_num(self) -> int: """The frame number. For VFR video or Timecode-backed objects, this is an approximation based on the average framerate. Prefer using `pts` and `time_base` for precise timing.""" if isinstance(self._time, Timecode): From 9377cf171db738e23c85bb2cbbbb1d881d81033a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 00:50:11 -0400 Subject: [PATCH 062/163] [common] Add TimecodeLike type hint --- scenedetect/__init__.py | 13 ++++++------- scenedetect/backends/moviepy.py | 4 ++-- scenedetect/backends/opencv.py | 14 +++++++++++--- scenedetect/backends/pyav.py | 6 ++++-- scenedetect/common.py | 7 +++++++ scenedetect/video_stream.py | 4 ++-- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 8db21640..6a27e72d 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -37,6 +37,7 @@ CutList, CropRegion, TimecodePair, + TimecodeLike, Interpolation, ) from scenedetect.video_stream import VideoStream, VideoOpenFailure @@ -136,8 +137,8 @@ def detect( detector: SceneDetector, stats_file_path: str | None = None, show_progress: bool = False, - start_time: str | float | int | None = None, - end_time: str | float | int | None = None, + start_time: TimecodeLike | None = None, + end_time: TimecodeLike | None = None, start_in_scene: bool = False, ) -> SceneList: """Perform scene detection on a given video `path` using the specified `detector`. @@ -170,10 +171,8 @@ def detect( """ video = open_video(video_path) if start_time is not None: - start_time = video.base_timecode + start_time - video.seek(start_time) - if end_time is not None: - end_time = video.base_timecode + end_time + video.seek(FrameTimecode(start_time, video.frame_rate)) + end_timecode = FrameTimecode(end_time, video.frame_rate) if end_time is not None else None # To reduce memory consumption when not required, we only add a StatsManager if we # need to save frame metrics to disk. scene_manager = SceneManager(StatsManager() if stats_file_path else None) @@ -181,7 +180,7 @@ def detect( scene_manager.detect_scenes( video=video, show_progress=show_progress, - end_time=end_time, + end_time=end_timecode, ) if scene_manager.stats_manager is not None: scene_manager.stats_manager.save_to_csv(csv_file=stats_file_path) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 3711c133..8c86534b 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -26,7 +26,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import FrameTimecode, Timecode, framerate_to_fraction +from scenedetect.common import FrameTimecode, Timecode, TimecodeLike, framerate_to_fraction from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream @@ -189,7 +189,7 @@ def frame_number(self) -> int: """ return self._frame_number - def seek(self, target: FrameTimecode | float | int): + def seek(self, target: TimecodeLike): """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 90697805..a7db82c8 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -27,7 +27,13 @@ import cv2 import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) from scenedetect.platform import get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, @@ -231,9 +237,11 @@ def frame_number(self) -> int: assert self._cap is not None return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) - def seek(self, target: FrameTimecode | float | int): + def seek(self, target: TimecodeLike): if self._is_device: raise SeekError("Cannot seek if input is a device!") + if not isinstance(target, FrameTimecode): + target = FrameTimecode(target, self.frame_rate) if target < 0: raise ValueError("Target seek position cannot be negative!") assert self._cap is not None @@ -484,7 +492,7 @@ def position_ms(self) -> float: def frame_number(self) -> int: return self._num_frames - def seek(self, target: FrameTimecode | float | int): + def seek(self, target: TimecodeLike): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 067e935f..366a4000 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -18,7 +18,7 @@ import av import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, TimecodeLike from scenedetect.platform import get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream @@ -234,7 +234,7 @@ def aspect_ratio(self) -> float: frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio - def seek(self, target: FrameTimecode | float | int) -> None: + def seek(self, target: TimecodeLike) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -252,6 +252,8 @@ def seek(self, target: FrameTimecode | float | int) -> None: Raises: ValueError: `target` is not a valid value (i.e. it is negative). """ + if not isinstance(target, FrameTimecode): + target = FrameTimecode(target, self.frame_rate) if target < 0: raise ValueError("Target cannot be negative!") beginning = target == 0 diff --git a/scenedetect/common.py b/scenedetect/common.py index a045298b..cd178d9b 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -715,3 +715,10 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None + + +TimecodeLike = int | float | str | Timecode | FrameTimecode +"""Type hint for values that can be converted to a :class:`FrameTimecode`. Accepts a frame number +(`int`), number of seconds (`float`), timecode string (`str` of the form ``HH:MM:SS[.nnn]``), a +:class:`Timecode`, or an existing :class:`FrameTimecode`. +""" diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 142df605..740a876d 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -36,7 +36,7 @@ import numpy as np -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike class SeekError(Exception): @@ -193,7 +193,7 @@ def reset(self) -> None: ... @abstractmethod - def seek(self, target: FrameTimecode | float | int) -> None: + def seek(self, target: TimecodeLike) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). From 268e2590b4160caf079b25c5010f8bcfc658db10 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 01:06:45 -0400 Subject: [PATCH 063/163] [lint] Use proper Path type hints rather than str | bytes --- scenedetect/__init__.py | 7 ++--- scenedetect/_cli/__init__.py | 48 ++++++++++++++++----------------- scenedetect/_cli/config.py | 6 ++--- scenedetect/_cli/context.py | 22 +++++++-------- scenedetect/backends/moviepy.py | 18 ++++++++----- scenedetect/backends/opencv.py | 47 +++++++++++++++++++------------- scenedetect/backends/pyav.py | 24 ++++++++++------- scenedetect/output/video.py | 2 +- scenedetect/platform.py | 18 ++++++------- scenedetect/video_stream.py | 4 +-- 10 files changed, 110 insertions(+), 86 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 6a27e72d..826f2f5b 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -40,6 +40,7 @@ TimecodeLike, Interpolation, ) +from scenedetect.platform import StrPath from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.output import ( save_images, @@ -80,7 +81,7 @@ def open_video( - path: str, + path: StrPath, framerate: float | None = None, backend: str = "opencv", **kwargs, @@ -133,9 +134,9 @@ def open_video( def detect( - video_path: str, + video_path: StrPath, detector: SceneDetector, - stats_file_path: str | None = None, + stats_file_path: StrPath | None = None, show_progress: bool = False, start_time: TimecodeLike | None = None, end_time: TimecodeLike | None = None, diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1ea5e81f..ca6f6cfa 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -319,10 +319,10 @@ def print_command_help(ctx: click.Context, command: click.Command): @click.pass_context def scenedetect( ctx: click.Context, - input: ty.AnyStr | None, - output: ty.AnyStr | None, - stats: ty.AnyStr | None, - config: ty.AnyStr | None, + input: str | None, + output: str | None, + stats: str | None, + config: str | None, framerate: float | None, min_scene_len: str | None, drop_short_scenes: bool | None, @@ -332,7 +332,7 @@ def scenedetect( downscale: int | None, frame_skip: int | None, verbosity: str | None, - logfile: ty.AnyStr | None, + logfile: str | None, quiet: bool, ): ctx = ctx.obj @@ -1052,7 +1052,7 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s @click.pass_context def save_html_command( ctx: click.Context, - filename: ty.AnyStr | None, + filename: str | None, no_images: bool, image_width: int | None, image_height: int | None, @@ -1144,8 +1144,8 @@ def save_html_command( @click.pass_context def list_scenes_command( ctx: click.Context, - output: ty.AnyStr | None, - filename: ty.AnyStr | None, + output: str | None, + filename: str | None, no_output_file: bool | None, quiet: bool | None, skip_cuts: bool | None, @@ -1280,8 +1280,8 @@ def list_scenes_command( @click.pass_context def split_video_command( ctx: click.Context, - output: ty.AnyStr | None, - filename: ty.AnyStr | None, + output: str | None, + filename: str | None, quiet: bool, copy: bool, high_quality: bool, @@ -1487,8 +1487,8 @@ def split_video_command( @click.pass_context def save_images_command( ctx: click.Context, - output: ty.AnyStr | None = None, - filename: ty.AnyStr | None = None, + output: str | None = None, + filename: str | None = None, num_images: int | None = None, jpeg: bool = False, webp: bool = False, @@ -1603,10 +1603,10 @@ def save_images_command( @click.pass_context def save_edl_command( ctx: click.Context, - filename: ty.AnyStr | None, - title: ty.AnyStr | None, - reel: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + title: str | None, + reel: str | None, + output: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1657,8 +1657,8 @@ def save_edl_command( @click.pass_context def save_qp_command( ctx: click.Context, - filename: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + output: str | None, disable_shift: bool | None, ): ctx = ctx.obj @@ -1706,9 +1706,9 @@ def save_qp_command( @click.pass_context def save_fcp_command( ctx: click.Context, - filename: ty.AnyStr | None, - format: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + format: str | None, + output: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1767,9 +1767,9 @@ def save_fcp_command( @click.pass_context def save_otio_command( ctx: click.Context, - filename: ty.AnyStr | None, - name: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + name: str | None, + output: str | None, audio: bool, no_audio: bool, ): diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 155ff3e1..ca95559c 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -318,11 +318,11 @@ class FcpFormat(Enum): ConfigValue = bool | int | float | str ConfigDict = dict[str, dict[str, ConfigValue]] -_CONFIG_FILE_NAME: ty.AnyStr = "scenedetect.cfg" -_CONFIG_FILE_DIR: ty.AnyStr = user_config_dir("PySceneDetect", False) +_CONFIG_FILE_NAME: str = "scenedetect.cfg" +_CONFIG_FILE_DIR: str = user_config_dir("PySceneDetect", False) _PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format -CONFIG_FILE_PATH: ty.AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) +CONFIG_FILE_PATH: str = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 838552ff..895409a4 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -159,21 +159,21 @@ def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameT def handle_options( self, - input_path: ty.AnyStr, - output: ty.AnyStr | None, - framerate: float, - stats_file: ty.AnyStr | None, - frame_skip: int, - min_scene_len: str, + input_path: str | None, + output: str | None, + framerate: float | None, + stats_file: str | None, + frame_skip: int | None, + min_scene_len: str | None, drop_short_scenes: bool | None, merge_last_scene: bool | None, backend: str | None, crop: tuple[int, int, int, int] | None, downscale: int | None, quiet: bool, - logfile: ty.AnyStr | None, - config: ty.AnyStr | None, - stats: ty.AnyStr | None, + logfile: str | None, + config: str | None, + stats: str | None, verbosity: str | None, ): """Parse all global options/arguments passed to the main scenedetect command, @@ -496,7 +496,7 @@ def _initialize_logging( self, quiet: bool | None = None, verbosity: str | None = None, - logfile: ty.AnyStr | None = None, + logfile: str | None = None, ): """Setup logging based on CLI args and user configuration settings.""" if quiet is not None: @@ -527,7 +527,7 @@ def _initialize_logging( def _open_video_stream( self, - input_path: ty.AnyStr, + input_path: str, framerate: float | None, backend: str | None, ): diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 8c86534b..826f4f3b 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -16,6 +16,7 @@ image sequences or AviSynth scripts are supported as inputs. """ +import os import time import typing as ty from fractions import Fraction @@ -26,8 +27,13 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import FrameTimecode, Timecode, TimecodeLike, framerate_to_fraction -from scenedetect.platform import get_file_name +from scenedetect.common import ( + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") @@ -63,7 +69,7 @@ def _retry_on_oserror(op_name: str, fn: ty.Callable): class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: bool = False): + def __init__(self, path: StrPath, framerate: float | None = None, print_infos: bool = False): """Open a video or device. Arguments: @@ -84,13 +90,13 @@ def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: "VideoStreamMoviePy does not support the `framerate` argument yet." ) - self._path = path + self._path: str = os.fspath(path) # TODO: Need to map errors based on the strings, since several failure # cases return IOErrors (e.g. could not read duration/video resolution). These # should be mapped to specific errors, e.g. write a function to map MoviePy # exceptions to a new set of equivalents. self._reader = _retry_on_oserror( - "open", lambda: FFMPEG_VideoReader(path, print_infos=print_infos) + "open", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) ) # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame @@ -117,7 +123,7 @@ def frame_rate(self) -> Fraction: return framerate_to_fraction(self._reader.fps) @property - def path(self) -> bytes | str: + def path(self) -> str: """Video path.""" return self._path diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index a7db82c8..e0985535 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -18,6 +18,7 @@ """ import math +import os import os.path import typing as ty import warnings @@ -34,7 +35,7 @@ TimecodeLike, framerate_to_fraction, ) -from scenedetect.platform import get_file_name +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, SeekError, @@ -71,10 +72,10 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: ty.AnyStr | None = None, + path: StrPath | None = None, framerate: float | None = None, max_decode_attempts: int = 5, - path_or_device: bytes | str | int | None = None, + path_or_device: StrPath | int | None = None, ): """Open a video file, image sequence, or network stream. @@ -103,15 +104,19 @@ def __init__( DeprecationWarning, stacklevel=2, ) - path = path_or_device - if path is None: + resolved: str | int = ( + path_or_device if isinstance(path_or_device, int) else os.fspath(path_or_device) + ) + elif path is None: raise ValueError("Path must be specified!") + else: + resolved = os.fspath(path) if framerate is not None and framerate < MAX_FPS_DELTA: raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") if max_decode_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") - self._path_or_device = path + self._path_or_device: str | int = resolved self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: @@ -156,11 +161,11 @@ def frame_rate(self) -> Fraction: return self._frame_rate @property - def path(self) -> bytes | str: + def path(self) -> str: if self._is_device: - assert isinstance(self._path_or_device, (int)) + assert isinstance(self._path_or_device, int) return f"Device {self._path_or_device}" - assert isinstance(self._path_or_device, (bytes, str)) + assert isinstance(self._path_or_device, str) return self._path_or_device @property @@ -316,15 +321,21 @@ def read(self, decode: bool = True) -> np.ndarray | bool: def _open_capture(self, framerate: float | None = None): """Opens capture referenced by this object and resets internal state.""" - if self._is_device and self._path_or_device < 0: - raise ValueError("Invalid/negative device ID specified.") - input_is_video_file = not self._is_device and not any( - identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS - ) - # We don't have a way of querying why opening a video fails (errors are logged at least), - # so provide a better error message if we try to open a file that doesn't exist. - if input_is_video_file and not os.path.exists(self._path_or_device): - raise OSError("Video file not found.") + if self._is_device: + assert isinstance(self._path_or_device, int) + if self._path_or_device < 0: + raise ValueError("Invalid/negative device ID specified.") + input_is_video_file = False + else: + assert isinstance(self._path_or_device, str) + input_is_video_file = not any( + identifier in self._path_or_device + for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS + ) + # We don't have a way of querying why opening a video fails (errors are logged at + # least), so provide a better error message if we try to open a missing file. + if input_is_video_file and not os.path.exists(self._path_or_device): + raise OSError("Video file not found.") cap = cv2.VideoCapture(self._path_or_device) if not cap.isOpened(): diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 366a4000..911f3697 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -11,6 +11,7 @@ # """:class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" +import os import typing as ty from fractions import Fraction from logging import getLogger @@ -18,8 +19,13 @@ import av import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, TimecodeLike -from scenedetect.platform import get_file_name +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameTimecode, + Timecode, + TimecodeLike, +) +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") @@ -35,7 +41,7 @@ class VideoStreamAv(VideoStream): # calculates the end time. def __init__( self, - path_or_io: ty.AnyStr | ty.BinaryIO, + path_or_io: StrPath | ty.BinaryIO, framerate: float | Fraction | None = None, name: str | None = None, threading_mode: str | None = None, @@ -98,12 +104,12 @@ def __init__( av.logging.restore_default_callback() try: - if isinstance(path_or_io, (str, bytes)): - self._path = path_or_io + if isinstance(path_or_io, (str, os.PathLike)): + self._path: str = os.fspath(path_or_io) # File handle is intentionally long-lived and tied to the VideoStream. - self._io = open(path_or_io, "rb") # noqa: SIM115 + self._io = open(self._path, "rb") # noqa: SIM115 if not self._name: - self._name = get_file_name(self.path, include_extension=False) + self._name = get_file_name(self._path, include_extension=False) else: self._io = path_or_io @@ -150,12 +156,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> bytes | str: + def path(self) -> str: """Video path.""" return self._path @property - def name(self) -> bytes | str: + def name(self) -> str: """Name of the video, without extension.""" return self._name diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 9434d4de..54bfb91e 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -120,7 +120,7 @@ class SceneMetadata: """Last frame.""" -PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], ty.AnyStr] +PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], str] def default_formatter(template: str) -> PathFormatter: diff --git a/scenedetect/platform.py b/scenedetect/platform.py index e26ce42b..0c41cd50 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -28,6 +28,10 @@ import cv2 +StrPath = str | os.PathLike[str] +"""Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike` +(e.g. :class:`pathlib.Path`).""" + ## ## tqdm Library ## @@ -108,24 +112,19 @@ def _get_cv2_param(param_name: str) -> int | None: ## -def get_file_name(file_path: ty.AnyStr, include_extension=True) -> ty.AnyStr: +def get_file_name(file_path: StrPath, include_extension: bool = True) -> str: """Return the file name that `file_path` refers to, optionally removing the extension. - If `include_extension` is False, the result will always be a str. - E.g. /tmp/foo.bar -> foo""" - file_name = os.path.basename(file_path) + file_name = os.path.basename(os.fspath(file_path)) if not include_extension: - file_name = str(file_name) last_dot_pos = file_name.rfind(".") if last_dot_pos >= 0: file_name = file_name[:last_dot_pos] return file_name -def get_and_create_path( - file_path: ty.AnyStr, output_directory: ty.AnyStr | None = None -) -> ty.AnyStr: +def get_and_create_path(file_path: StrPath, output_directory: StrPath | None = None) -> str: """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories along the way. @@ -143,10 +142,11 @@ def get_and_create_path( Full path to output file suitable for writing. """ + file_path = os.fspath(file_path) # If an output directory is defined and the file path is a relative path, open # the file handle in the output directory instead of the working directory. if output_directory is not None and not os.path.isabs(file_path): - file_path = os.path.join(output_directory, file_path) + file_path = os.path.join(os.fspath(output_directory), file_path) # Now that file_path is an absolute path, let's make sure all the directories # exist for us to start writing files there. os.makedirs(os.path.split(os.path.abspath(file_path))[0], exist_ok=True) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 740a876d..502390c7 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -104,13 +104,13 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> bytes | str: + def path(self) -> str: """Video or device path.""" ... @property @abstractmethod - def name(self) -> bytes | str: + def name(self) -> str: """Name of the video, without extension, or device.""" ... From c5084417aff3ced0ffe6caea96dd94507c1b894e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 01:22:48 -0400 Subject: [PATCH 064/163] [cli] Strengthen type guards --- scenedetect/_cli/__init__.py | 72 ++++++++++++++-------------- scenedetect/_cli/config.py | 19 ++++++-- scenedetect/_cli/context.py | 91 +++++++++++------------------------- scenedetect/common.py | 18 +++---- 4 files changed, 86 insertions(+), 114 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ca6f6cfa..f4ab2898 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,7 +27,7 @@ import click -import scenedetect +import scenedetect as scenedetect_pkg import scenedetect._cli.commands as cli_commands from scenedetect._cli.config import ( CHOICE_MAP, @@ -35,6 +35,7 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, + RangeValue, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS @@ -47,13 +48,24 @@ ) from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info -PROGRAM_VERSION = scenedetect.__version__ +PROGRAM_VERSION = scenedetect_pkg.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" logger = logging.getLogger("pyscenedetect") LINE_SEPARATOR = "-" * 72 + +def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": + """Return a `click` parameter type matching the `RangeValue` at `CONFIG_MAP[section][key]`. + + Used in `@click.option(... type=...)` decorators so each option's bounds and value type are + sourced from the canonical `CONFIG_MAP` entry. + """ + val = CONFIG_MAP[section][key] + assert isinstance(val, RangeValue), f"Expected RangeValue at {section}/{key}, got {type(val)}" + return val.click_range + # About & copyright message string shown for the 'about' CLI command (scenedetect about). ABOUT_STRING = """ Site: http://scenedetect.com/ @@ -375,7 +387,8 @@ def add_hidden_alias(command: click.Command, alias: str): def help_command(ctx: click.Context, command_name: str): """Print full help reference.""" # TODO: Other commands still seem to run if this is specified. - assert isinstance(ctx.parent.command, click.MultiCommand) + assert ctx.parent is not None + assert isinstance(ctx.parent.command, click.Group) parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) if command_name is not None: @@ -386,11 +399,15 @@ def help_command(ctx: click.Context, command_name: str): ] raise click.BadParameter("\n".join(error_strs), param_hint="command") click.echo("") - print_command_help(ctx, parent_command.get_command(ctx, command_name)) + target = parent_command.get_command(ctx, command_name) + assert target is not None + print_command_help(ctx, target) else: click.echo(ctx.parent.get_help()) for command in sorted(all_commands): - print_command_help(ctx, parent_command.get_command(ctx, command)) + target = parent_command.get_command(ctx, command) + assert target is not None + print_command_help(ctx, target) ctx.exit() @@ -509,10 +526,7 @@ def time_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-content"]["threshold"].min_val, - CONFIG_MAP["detect-content"]["threshold"].max_val, - ), + type=_click_range("detect-content", "threshold"), default=None, help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.{}'.format( USER_CONFIG.get_help_string("detect-content", "threshold") @@ -720,10 +734,7 @@ def detect_adaptive_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-threshold"]["threshold"].min_val, - CONFIG_MAP["detect-threshold"]["threshold"].max_val, - ), + type=_click_range("detect-threshold", "threshold"), default=None, help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.{}'.format( USER_CONFIG.get_help_string("detect-threshold", "threshold") @@ -733,10 +744,7 @@ def detect_adaptive_command( "--fade-bias", "-f", metavar="PERCENT", - type=click.FloatRange( - CONFIG_MAP["detect-threshold"]["fade-bias"].min_val, - CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, - ), + type=_click_range("detect-threshold", "fade-bias"), default=None, help="Percent (%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.{}".format( USER_CONFIG.get_help_string("detect-threshold", "fade-bias") @@ -802,10 +810,7 @@ def detect_threshold_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-hist"]["threshold"].min_val, - CONFIG_MAP["detect-hist"]["threshold"].max_val, - ), + type=_click_range("detect-hist", "threshold"), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " "values are more sensitive to changes.{}".format( @@ -816,9 +821,7 @@ def detect_threshold_command( "--bins", "-b", metavar="NUM", - type=click.IntRange( - CONFIG_MAP["detect-hist"]["bins"].min_val, CONFIG_MAP["detect-hist"]["bins"].max_val - ), + type=_click_range("detect-hist", "bins"), default=None, help="The number of bins to use for the histogram calculation.{}".format( USER_CONFIG.get_help_string("detect-hist", "bins") @@ -873,10 +876,7 @@ def detect_hist_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-hash"]["threshold"].min_val, - CONFIG_MAP["detect-hash"]["threshold"].max_val, - ), + type=_click_range("detect-hash", "threshold"), default=None, help=( "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " @@ -889,9 +889,7 @@ def detect_hist_command( "--size", "-s", metavar="SIZE", - type=click.IntRange( - CONFIG_MAP["detect-hash"]["size"].min_val, CONFIG_MAP["detect-hash"]["size"].max_val - ), + type=_click_range("detect-hash", "size"), default=None, help="Size of square of low frequency data to include from the discrete cosine transform.{}".format( USER_CONFIG.get_help_string("detect-hash", "size") @@ -901,9 +899,7 @@ def detect_hist_command( "--lowpass", "-l", metavar="FRAC", - type=click.IntRange( - CONFIG_MAP["detect-hash"]["lowpass"].min_val, CONFIG_MAP["detect-hash"]["lowpass"].max_val - ), + type=_click_range("detect-hash", "lowpass"), default=None, help=( "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " @@ -984,6 +980,8 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s raise click.ClickException("The load-scenes command cannot be used with detectors.") if ctx.load_scenes_input: raise click.ClickException("The load-scenes command must only be specified once.") + if input is None: + raise click.BadParameter("Input file is required.", param_hint="-i/--input") input = os.path.abspath(input) if not os.path.exists(input): raise click.BadParameter( @@ -1066,6 +1064,7 @@ def save_html_command( # to include images. include_images = not ctx.config.get_value("save-html", "no-images", no_images) if include_images and not ctx.save_images: + assert save_images_command.callback is not None save_images_command.callback() save_html_args = { "filename": ctx.config.get_value("save-html", "filename", filename), @@ -1239,10 +1238,7 @@ def list_scenes_command( "-crf", metavar="RATE", default=None, - type=click.IntRange( - CONFIG_MAP["split-video"]["rate-factor"].min_val, - CONFIG_MAP["split-video"]["rate-factor"].max_val, - ), + type=_click_range("split-video", "rate-factor"), help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.{}".format( USER_CONFIG.get_help_string("split-video", "rate-factor") ), diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index ca95559c..60d4a57a 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -23,6 +23,7 @@ from configparser import Error as ConfigParserError from enum import Enum +import click from platformdirs import user_config_dir from scenedetect.common import FrameTimecode @@ -124,6 +125,13 @@ def max_val(self) -> int | float: """Maximum value of the range.""" return self._max_val + @property + def click_range(self) -> "click.IntRange | click.FloatRange": + """A `click` parameter type matching this range's bounds and value type.""" + if isinstance(self._value, int): + return click.IntRange(int(self._min_val), int(self._max_val)) + return click.FloatRange(float(self._min_val), float(self._max_val)) + @staticmethod def from_config(config_value: str, default: "RangeValue") -> "RangeValue": try: @@ -757,9 +765,14 @@ def get_value( self, command: str, option: str, - override: ConfigValue | None = None, - ) -> ConfigValue: - """Get the current setting or default value of the specified command option.""" + override: ty.Any = None, + ) -> ty.Any: + """Get the current setting or default value of the specified command option. + + Returns ``ty.Any`` because each (command, option) pair has a known concrete type at + the call site, but the union across all options is too wide to be useful as a return + annotation. Callers should know the expected type for the option they are reading. + """ assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: value = override diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 895409a4..cc6eb458 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -133,6 +133,22 @@ def ensure_detector(self): (detector_type, detector_args) = self.default_detector self.add_detector(detector_type, detector_args) + def _resolve_min_scene_len(self, command: str, override: str | None) -> int: + """Resolve the minimum scene length (in frames) for a `detect-*` command, honoring + the `--drop-short-scenes` flag, command-specific config, and global default.""" + if self.drop_short_scenes: + return 0 + if override is not None: + parsed = self.parse_timecode(override) + assert parsed is not None + return parsed.frame_num + if self.config.is_default(command, "min-scene-len"): + assert self.min_scene_len is not None + return self.min_scene_len.frame_num + parsed = self.parse_timecode(self.config.get_value(command, "min-scene-len")) + assert parsed is not None + return parsed.frame_num + def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode | None: """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` is None it will be passed through without processing. @@ -328,18 +344,7 @@ def get_detect_content_params( filter_mode: str | None = None, ) -> dict[str, ty.Any]: """Get a dict containing user options to construct a ContentDetector with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-content", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-content", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-content", min_scene_len) if weights is not None: try: @@ -354,7 +359,7 @@ def get_detect_content_params( "weights": self.config.get_value("detect-content", "weights", weights), "kernel_size": self.config.get_value("detect-content", "kernel-size", kernel_size), "luma_only": luma_only or self.config.get_value("detect-content", "luma-only"), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "threshold": self.config.get_value("detect-content", "threshold", threshold), "filter_mode": self.config.get_value("detect-content", "filter-mode", filter_mode), } @@ -371,18 +376,7 @@ def get_detect_adaptive_params( ) -> dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-adaptive", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-adaptive", min_scene_len) if weights is not None: try: @@ -400,7 +394,7 @@ def get_detect_adaptive_params( "min_content_val": self.config.get_value( "detect-adaptive", "min-content-val", min_content_val ), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "window_width": self.config.get_value("detect-adaptive", "frame-window", frame_window), } @@ -413,24 +407,13 @@ def get_detect_threshold_params( ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-threshold", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-threshold", min_scene_len) # TODO(v1.0): add_last_scene cannot be disabled right now. return { "add_final_scene": add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"), "fade_bias": self.config.get_value("detect-threshold", "fade-bias", fade_bias), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "threshold": self.config.get_value("detect-threshold", "threshold", threshold), } @@ -442,21 +425,10 @@ def get_detect_hist_params( ) -> dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hist", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-hist", min_scene_len) return { "bins": self.config.get_value("detect-hist", "bins", bins), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "threshold": self.config.get_value("detect-hist", "threshold", threshold), } @@ -469,21 +441,10 @@ def get_detect_hash_params( ) -> dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hash", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-hash", min_scene_len) return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "size": self.config.get_value("detect-hash", "size", size), "threshold": self.config.get_value("detect-hash", "threshold", threshold), } diff --git a/scenedetect/common.py b/scenedetect/common.py index cd178d9b..bf40d34f 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -503,7 +503,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num == other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -515,7 +515,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num != other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -525,7 +525,7 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num < other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -535,7 +535,7 @@ def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num <= other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -545,7 +545,7 @@ def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num > other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -555,7 +555,7 @@ def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num >= other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -713,8 +713,10 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") -def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: - return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None +def _compare_as_fixed(other: ty.Any, base: FrameTimecode) -> ty.TypeGuard[FrameTimecode]: + """Type guard: True (and narrows `other` to `FrameTimecode`) iff both timecodes have a known + framerate, in which case frame-based comparison is exact and preferred over float seconds.""" + return base._rate is not None and isinstance(other, FrameTimecode) and other._rate is not None TimecodeLike = int | float | str | Timecode | FrameTimecode From 7f2b25b99d9dd2824e6e0ff50236d73bf78ea5e8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 01:38:16 -0400 Subject: [PATCH 065/163] [lint] Fix more type hint errors --- scenedetect/__init__.py | 4 ++-- scenedetect/_cli/__init__.py | 1 + scenedetect/_cli/controller.py | 8 ++++--- scenedetect/backends/moviepy.py | 10 ++++----- scenedetect/backends/pyav.py | 8 +++---- scenedetect/detector.py | 23 +++++++++++++------- scenedetect/detectors/hash_detector.py | 6 +++--- scenedetect/detectors/threshold_detector.py | 11 +++++----- scenedetect/output/__init__.py | 4 +++- scenedetect/output/image.py | 21 +++++++++++------- scenedetect/output/video.py | 17 ++++++++------- scenedetect/scene_manager.py | 24 +++++++++++++-------- scenedetect/stats_manager.py | 9 +++++--- scenedetect/video_stream.py | 11 ++++------ 14 files changed, 89 insertions(+), 68 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 826f2f5b..19460d7c 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -105,7 +105,7 @@ def open_video( :class:`VideoOpenFailure`: Constructing the VideoStream fails. If multiple backends have been attempted, the error from the first backend will be returned. """ - last_error: Exception = None + last_error: Exception | None = None # If `backend` is available, try to open the video at `path` using it. if backend in AVAILABLE_BACKENDS: backend_type = AVAILABLE_BACKENDS[backend] @@ -183,6 +183,6 @@ def detect( show_progress=show_progress, end_time=end_timecode, ) - if scene_manager.stats_manager is not None: + if scene_manager.stats_manager is not None and stats_file_path is not None: scene_manager.stats_manager.save_to_csv(csv_file=stats_file_path) return scene_manager.get_scene_list(start_in_scene=start_in_scene) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index f4ab2898..16dfce5b 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -66,6 +66,7 @@ def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": assert isinstance(val, RangeValue), f"Expected RangeValue at {section}/{key}, got {type(val)}" return val.click_range + # About & copyright message string shown for the 'about' CLI command (scenedetect about). ABOUT_STRING = """ Site: http://scenedetect.com/ diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index a53ea77e..aa43a45e 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -209,11 +209,13 @@ def calculate_timecode(value: str) -> FrameTimecode: start_time = context.start_time cut_list = [cut for cut in cut_list if cut > context.start_time] - end_time = context.video_stream.duration + video_duration = context.video_stream.duration + assert video_duration is not None + end_time = video_duration if context.end_time is not None: - end_time = min(context.end_time, context.video_stream.duration) + end_time = min(context.end_time, video_duration) elif context.duration is not None: - end_time = min(start_time + context.duration, context.video_stream.duration) + end_time = min(start_time + context.duration, video_duration) cut_list = [cut for cut in cut_list if cut < end_time] scene_list = get_scenes_from_cuts(cut_list=cut_list, start_pos=start_time, end_pos=end_time) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 826f4f3b..ad8a849a 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -267,10 +267,8 @@ def read(self, decode: bool = True) -> np.ndarray | bool: return False self._eof = True self._frame_number += 1 - if decode: - last_frame_valid = self._last_frame is not None and self._last_frame is not False - if last_frame_valid: - self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) - assert self._last_frame_rgb is not None - return self._last_frame_rgb + if decode and isinstance(self._last_frame, np.ndarray): + self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) + assert self._last_frame_rgb is not None + return self._last_frame_rgb return not self._eof diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 911f3697..e297600b 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -74,7 +74,7 @@ def __init__( VideoOpenFailure: video could not be opened (may be corrupted) ValueError: specified framerate is invalid """ - self._container: av.container.InputContainer | None = None + self._container: av.container.InputContainer | None = None # type: ignore[name-defined] # TODO(https://scenedetect.com/issues/258): See what # `self._container.discard_corrupt = True` does with corrupt videos. @@ -93,7 +93,7 @@ def __init__( if threading_mode: try: - threading_mode = av.codec.context.ThreadType[threading_mode.upper()] + threading_mode = av.codec.context.ThreadType[threading_mode.upper()] # type: ignore[attr-defined] except KeyError as _: raise ValueError( f"Invalid threading mode! Must be one of: {VALID_THREAD_MODES}" @@ -101,7 +101,7 @@ def __init__( if not suppress_output: logger.debug("Restoring default ffmpeg log callbacks.") - av.logging.restore_default_callback() + av.logging.restore_default_callback() # type: ignore[attr-defined] try: if isinstance(path_or_io, (str, os.PathLike)): @@ -305,7 +305,7 @@ def read(self, decode: bool = True) -> np.ndarray | bool: assert self._decoder is not None self._frame = next(self._decoder) self._decode_count += 1 - except av.error.EOFError: + except av.error.EOFError: # type: ignore[attr-defined] self._frame = last_frame if self._handle_eof(): return self.read(decode) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 3f7dcf0a..ba65b2a6 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -134,10 +134,10 @@ def __init__(self, mode: Mode, length: int | float | str): self._filter_secs = FrameTimecode(timecode=length, fps=100.0).seconds else: self._filter_length = int(length) - self._last_above = None # Last frame above threshold. + self._last_above: FrameTimecode | None = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filter. + self._merge_start: FrameTimecode | None = None # Frame where we started merging. @property def max_behind(self) -> int: @@ -165,12 +165,16 @@ def filter(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTi return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") - def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: - assert timecode.framerate >= 0 + def _filter_suppress( + self, timecode: FrameTimecode, above_threshold: bool + ) -> list[FrameTimecode]: + framerate = timecode.framerate + assert framerate is not None and framerate >= 0 + assert self._last_above is not None # Compute the threshold in seconds once from the first frame's framerate. This avoids # using an incorrect average fps (e.g. OpenCV on VFR video) on subsequent frames. if self._filter_secs is None: - self._filter_secs = self._filter_length / timecode.framerate + self._filter_secs = self._filter_length / framerate min_length_met: bool = (timecode - self._last_above) >= self._filter_secs if not (above_threshold and min_length_met): return [] @@ -179,17 +183,20 @@ def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> li self._last_above = timecode return [timecode] - def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: - assert timecode.framerate >= 0 + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTimecode]: + framerate = timecode.framerate + assert framerate is not None and framerate >= 0 + assert self._last_above is not None # Compute the threshold in seconds once from the first frame's framerate. if self._filter_secs is None: - self._filter_secs = self._filter_length / timecode.framerate + self._filter_secs = self._filter_length / framerate min_length_met: bool = (timecode - self._last_above) >= self._filter_secs # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. + assert self._merge_start is not None if ( min_length_met and not above_threshold diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 86c15f57..40b4e3c8 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -57,8 +57,8 @@ def __init__( self._size = size self._size_sq = float(size * size) self._factor = lowpass - self._last_frame: numpy.ndarray = None - self._last_scene_cut: FrameTimecode = None + self._last_frame: numpy.ndarray | None = None + self._last_scene_cut: FrameTimecode | None = None self._last_hash = numpy.array([]) self._metric_key = f"hash_dist [size={self._size} lowpass={self._factor}]" @@ -136,7 +136,7 @@ def hash_frame(frame_img, hash_size, factor) -> numpy.ndarray: max_value = 1 # Calculate discrete cosine tranformation of the image - resized_img = numpy.float32(resized_img) / max_value + resized_img = numpy.asarray(numpy.float32(resized_img) / max_value) dct_complete = cv2.dct(resized_img) # Only keep the low frequency information diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 1a5e4690..dd8d2a8a 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -15,6 +15,7 @@ This detector is available from the command-line as the `detect-threshold` command. """ +import typing as ty import warnings from enum import Enum from logging import getLogger @@ -82,12 +83,12 @@ def __init__( self.fade_bias = fade_bias self.min_scene_len = min_scene_len self.processed_frame = False - self.last_scene_cut = None + self.last_scene_cut: FrameTimecode | None = None # Whether to add an additional scene or not when ending on a fade out # (as cuts are only added on fade ins; see post_process() for details). self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. - self.last_fade = { + self.last_fade: dict[str, ty.Any] = { "frame": None, # FrameTimecode where the last detected fade is "type": None, # type of fade, can be either 'in' or 'out' } @@ -174,14 +175,12 @@ def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cuts: list[FrameTimecode] = [] + elapsed = timecode if self.last_scene_cut is None else timecode - self.last_scene_cut if ( self.last_fade["type"] == "out" and self.add_final_scene and self.last_fade["frame"] is not None - and ( - (self.last_scene_cut is None and timecode >= self.min_scene_len) - or (timecode - self.last_scene_cut) >= self.min_scene_len - ) + and elapsed >= self.min_scene_len ): cuts.append(self.last_fade["frame"]) return cuts diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index d2b28b4b..ceff96c3 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -244,10 +244,12 @@ def write_scene_list_html( def _edl_timecode(timecode: FrameTimecode) -> str: """Format `timecode` as ``HH:MM:SS:FF`` for a CMX 3600 EDL entry.""" total_seconds = timecode.seconds + framerate = timecode.framerate + assert framerate is not None hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) seconds = int(total_seconds % 60) - frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) + frames_part = int((total_seconds * framerate) % framerate) return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 0d95981b..902bcaeb 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -44,6 +44,7 @@ def _generate_timecode_list( `frame_margin` accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``). """ framerate = scene_list[0][0].framerate + assert framerate is not None margin_secs = FrameTimecode(timecode=frame_margin, fps=framerate).seconds result = [] for start, end in scene_list: @@ -71,7 +72,7 @@ def _generate_timecode_list( def _scale_image( image: np.ndarray, - aspect_ratio: float, + aspect_ratio: float | None, height: int | None, width: int | None, scale: float | None, @@ -90,9 +91,11 @@ def _scale_image( if height and not width: factor = height / float(image_height) width = int(factor * image_width) - if width and not height: + elif width and not height: factor = width / float(image_width) height = int(factor * image_height) + assert height is not None + assert width is not None assert height > 0 and width > 0 image = cv2.resize(image, (width, height), interpolation=interpolation.value) elif scale: @@ -106,7 +109,7 @@ def __init__( num_images: int = 3, frame_margin: int | float | str = 1, image_extension: str = "jpg", - imwrite_param: dict[str, int | None] | None = None, + imwrite_param: list[int] | None = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", scale: float | None = None, height: int | None = None, @@ -153,7 +156,7 @@ def __init__( self._height = height self._width = width self._interpolation = interpolation - self._imwrite_param = imwrite_param if imwrite_param else {} + self._imwrite_param: list[int] = imwrite_param if imwrite_param is not None else [] def run( self, @@ -337,7 +340,7 @@ def generate_timecode_list(self, scene_list: SceneList) -> list[list[FrameTimeco def resize_image( self, image: np.ndarray, - aspect_ratio: float, + aspect_ratio: float | None, ) -> np.ndarray: return _scale_image( image, aspect_ratio, self._height, self._width, self._scale, self._interpolation @@ -436,7 +439,7 @@ def save_images( width, interpolation, ) - return extractor.run(video, scene_list, output_dir, show_progress) + return extractor.run(video, scene_list, output_dir, bool(show_progress)) # Setup flags and init progress bar if available. completed = True @@ -467,7 +470,7 @@ def save_images( for j, image_timecode in enumerate(scene_timecodes): video.seek(image_timecode) frame_im = video.read() - if frame_im is not None and frame_im is not False: + if isinstance(frame_im, np.ndarray): # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. file_path = "{}.{}".format( @@ -495,9 +498,11 @@ def save_images( if height and not width: factor = height / float(frame_height) width = int(factor * frame_width) - if width and not height: + elif width and not height: factor = width / float(frame_width) height = int(factor * frame_height) + assert height is not None + assert width is not None assert height > 0 and width > 0 frame_im = cv2.resize( frame_im, (width, height), interpolation=interpolation.value diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 54bfb91e..35f8841e 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -34,6 +34,7 @@ import math import time import typing as ty +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -157,9 +158,9 @@ def formatter(video: VideoMetadata, scene: SceneMetadata) -> str: def split_video_mkvmerge( input_video_path: str, - scene_list: ty.Iterable[TimecodePair], + scene_list: Sequence[TimecodePair], output_dir: str | Path | None = None, - output_file_template: str | Path | None = "$VIDEO_NAME.mkv", + output_file_template: str = "$VIDEO_NAME.mkv", video_name: str | None = None, show_output: bool = False, suppress_output=None, @@ -253,8 +254,8 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, - scene_list: ty.Iterable[TimecodePair], - output_dir: Path | None = None, + scene_list: Sequence[TimecodePair], + output_dir: str | Path | None = None, output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", video_name: str | None = None, arg_override: str = _DEFAULT_FFMPEG_ARGS, @@ -312,14 +313,14 @@ def split_video_ffmpeg( arg_override = arg_override.replace('\\"', '"') ret_val = 0 - arg_override = arg_override.split(" ") + ffmpeg_args = arg_override.split(" ") scene_num_format = "%0" scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" if formatter is None: formatter = default_formatter(output_file_template) video_metadata = VideoMetadata( - name=video_name, path=input_video_path, total_scenes=len(scene_list) + name=video_name, path=Path(input_video_path), total_scenes=len(scene_list) ) try: @@ -331,7 +332,7 @@ def split_video_ffmpeg( for i, (start_time, end_time) in enumerate(scene_list): duration = end_time - start_time scene_metadata = SceneMetadata(index=i, start=start_time, end=end_time) - output_path = Path(formatter(scene=scene_metadata, video=video_metadata)) + output_path = Path(formatter(video_metadata, scene_metadata)) if output_dir: output_path = Path(output_dir) / output_path output_path.parent.mkdir(parents=True, exist_ok=True) @@ -355,7 +356,7 @@ def split_video_ffmpeg( "-t", str(duration.seconds), ] - call_list += arg_override + call_list += ffmpeg_args call_list += ["-sn"] call_list += [str(output_path)] ret_val = invoke_command(call_list) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index ca9e00a6..b1034e0d 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -213,11 +213,11 @@ def __init__( self._stats_manager: StatsManager | None = stats_manager # Position of video that was first passed to detect_scenes. - self._start_pos: FrameTimecode = None + self._start_pos: FrameTimecode | None = None # Position of video on the last frame processed by detect_scenes. - self._last_pos: FrameTimecode = None + self._last_pos: FrameTimecode | None = None # Size of the decoded frames. - self._frame_size: tuple[int, int] = None + self._frame_size: tuple[int, int] | None = None self._frame_size_errors: int = 0 self._base_timecode: FrameTimecode | None = None self._downscale: int = 1 @@ -361,7 +361,7 @@ def get_scene_list(self, start_in_scene: bool = False) -> SceneList: end_time are FrameTimecode objects representing the exact time/frame where each detected scene in the video begins and ends. """ - if self._base_timecode is None: + if self._base_timecode is None or self._start_pos is None or self._last_pos is None: return [] cut_list = self._get_cutting_list() scene_list = get_scenes_from_cuts( @@ -404,7 +404,7 @@ def _process_frame( for cut in cuts: for position, frame in self._frame_buffer: if cut == position: - callback(frame, int(position)) + callback(frame, position) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: @@ -418,12 +418,12 @@ def stop(self) -> None: def detect_scenes( self, - video: VideoStream = None, + video: VideoStream | None = None, duration: FrameTimecode | None = None, end_time: FrameTimecode | None = None, frame_skip: int = 0, show_progress: bool = False, - callback: ty.Callable[[np.ndarray, int], None] | None = None, + callback: ty.Callable[[np.ndarray, FrameTimecode], None] | None = None, frame_source: VideoStream | None = None, ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the @@ -480,7 +480,9 @@ def detect_scenes( effective_frame_size = video.frame_size if self._crop: - logger.debug(f"Crop set: top left = {self.crop[0:2]}, bottom right = {self.crop[2:4]}") + logger.debug( + f"Crop set: top left = {self._crop[0:2]}, bottom right = {self._crop[2:4]}" + ) x0, y0, x1, y1 = self._crop min_x, min_y = (min(x0, x1), min(y0, y1)) max_x, max_y = (max(x0, x1), max(y0, y1)) @@ -550,6 +552,7 @@ def detect_scenes( break if next_frame is not None: frame_im = next_frame + assert frame_im is not None new_cuts = self._process_frame(position, frame_im, callback) if progress_bar is not None: if new_cuts: @@ -570,7 +573,9 @@ def detect_scenes( decode_thread.join() if self._exception_info is not None: - raise self._exception_info[1].with_traceback(self._exception_info[2]) + exc = self._exception_info[1] + assert exc is not None + raise exc.with_traceback(self._exception_info[2]) self._last_pos = video.position self._post_process(video.position) @@ -594,6 +599,7 @@ def _decode_thread( frame_im = video.read() if frame_im is False: break + assert isinstance(frame_im, np.ndarray) # Verify the decoded frame size against the video container's reported # resolution, and also verify that consecutive frames have the correct size. decoded_size = (frame_im.shape[1], frame_im.shape[0]) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index d61ac789..d2292f67 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -22,12 +22,14 @@ """ import csv +import os import os.path import typing as ty from logging import getLogger from pathlib import Path from scenedetect.common import FrameTimecode +from scenedetect.platform import StrPath logger = getLogger("pyscenedetect") @@ -94,7 +96,7 @@ class StatsManager: Only metrics consisting of `float` or `int` should be used currently. """ - def __init__(self, base_timecode: FrameTimecode = None): + def __init__(self, base_timecode: FrameTimecode | None = None): """Initialize a new StatsManager. Arguments: @@ -156,7 +158,7 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: str | bytes | Path | ty.TextIO, + csv_file: StrPath | ty.TextIO, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. @@ -174,10 +176,11 @@ def save_to_csv( # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. - if isinstance(csv_file, (str, bytes, Path)): + if isinstance(csv_file, (str, bytes, Path, os.PathLike)): with open(csv_file, "w") as file: self.save_to_csv(csv_file=file, force_save=force_save) return + # csv_file is now narrowed to ty.TextIO (the path branch returned above). csv_writer = csv.writer(csv_file, lineterminator="\n") metric_keys = sorted(list(self._metric_keys)) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 502390c7..3c3687a1 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -31,6 +31,7 @@ tested by adding it to the test suite in `tests/test_video_stream.py`. """ +import typing as ty from abc import ABC, abstractmethod from fractions import Fraction @@ -88,15 +89,11 @@ def base_timecode(self) -> FrameTimecode: return FrameTimecode(timecode=0, fps=self.frame_rate) # - # Abstract Static Methods + # Backend Identification # - @staticmethod - @abstractmethod - def BACKEND_NAME() -> str: - """Unique name used to identify this backend. Should be a static property in derived - classes (`BACKEND_NAME = 'backend_identifier'`).""" - ... + BACKEND_NAME: ty.ClassVar[str] + """Unique name used to identify this backend. Each subclass must set this to a unique str.""" # # Abstract Properties From ed6a725b873cdbec7dcf652bfccbb925d3aabd89 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 12:59:51 -0400 Subject: [PATCH 066/163] [backends] Tighten None safety, reduce unnecessary None checks --- pyproject.toml | 26 +++++++++++++++++++++++--- scenedetect/backends/opencv.py | 27 +++------------------------ scenedetect/backends/pyav.py | 15 +++++---------- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da7da0d9..35b9e85b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,15 +61,35 @@ unfixable = [] [tool.pyright] include = ["scenedetect", "tests"] +# Run pyright from inside an activated venv (or pass `--pythonpath` / +# configure your editor's interpreter) so cv2 / av / numpy / moviepy +# resolve. Without this, pyright uses its bundled Python and the report +# fills with cascading "import could not be resolved" noise. +# Per-developer venv paths are deliberately NOT pinned here. +# +# Modes: "off" | "basic" | "standard" | "strict". +# We're at "basic" for 0.7. Bumping requires the cleanup in TODO(0.8) below. typeCheckingMode = "basic" -# cv2, av, and moviepy ship without type stubs; these reports generate -# unactionable noise without catching real issues in this codebase. + +# Third-party noise: cv2, av, moviepy ship without (or with partial) type +# stubs; these reports are unactionable in this codebase. reportMissingTypeStubs = "none" reportUnknownMemberType = "none" reportUnknownArgumentType = "none" reportUnknownVariableType = "none" reportUnknownParameterType = "none" + +# Click + pytest decorators are conventionally untyped. +reportUntypedFunctionDecorator = "none" + +# TODO(0.8): Audit the rules below. They were added globally with a +# third-party justification, but they affect the whole codebase. After the +# remaining first-party type-debt is cleared (FrameTimecode/TimecodeLike in +# common.py, _cli/config.py CropValue overloads, Click CLI typing in +# _cli/__init__.py), re-enable each in isolation and decide keep/drop: +# reportMissingParameterType — first-party coverage looks complete +# reportMissingTypeArgument — modern Python defaults absorb most +# reportPrivateUsage — no cross-module _private access seen reportMissingParameterType = "none" reportMissingTypeArgument = "none" -reportUntypedFunctionDecorator = "none" reportPrivateUsage = "none" diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index e0985535..fb64585e 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -119,18 +119,13 @@ def __init__( self._path_or_device: str | int = resolved self._is_device = isinstance(self._path_or_device, int) - # Initialized in _open_capture: - self._cap: cv2.VideoCapture | None = ( - None # Reference to underlying cv2.VideoCapture object. - ) - self._frame_rate: Fraction | None = None - # VideoCapture state self._has_grabbed = False self._max_decode_attempts = max_decode_attempts self._decode_failures = 0 self._warning_displayed = False + # `_open_capture` populates `_cap` and `_frame_rate`. self._open_capture(framerate) # @@ -145,7 +140,6 @@ def capture(self) -> cv2.VideoCapture: backing this object. Seeking or using the read/grab methods through this property are unsupported and will leave this object in an inconsistent state. """ - assert self._cap return self._cap # @@ -157,7 +151,6 @@ def capture(self) -> cv2.VideoCapture: @property def frame_rate(self) -> Fraction: - assert self._frame_rate return self._frame_rate @property @@ -189,7 +182,6 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - assert self._cap is not None return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), @@ -200,13 +192,11 @@ def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None - assert self._cap is not None return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - assert self._cap is not None return _get_aspect_ratio(self._cap) @property @@ -215,7 +205,6 @@ def timecode(self) -> Timecode: # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to # avoid frame-boundary rounding errors at common framerates like 24000/1001. - assert self._cap is not None ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) time_base = Fraction(1, 1000000) return Timecode(pts=round(ms * 1000), time_base=time_base) @@ -234,12 +223,10 @@ def position(self) -> FrameTimecode: @property def position_ms(self) -> float: - assert self._cap is not None return self._cap.get(cv2.CAP_PROP_POS_MSEC) @property def frame_number(self) -> int: - assert self._cap is not None return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) def seek(self, target: TimecodeLike): @@ -249,9 +236,6 @@ def seek(self, target: TimecodeLike): target = FrameTimecode(target, self.frame_rate) if target < 0: raise ValueError("Target seek position cannot be negative!") - assert self._cap is not None - - assert self._frame_rate is not None target_secs = (self.base_timecode + target).seconds self._has_grabbed = False if target_secs > 0: @@ -281,13 +265,10 @@ def seek(self, target: TimecodeLike): def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - assert self._cap is not None - assert self._frame_rate is not None self._cap.release() self._open_capture(float(self._frame_rate)) def read(self, decode: bool = True) -> np.ndarray | bool: - assert self._cap is not None if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() @@ -364,8 +345,8 @@ def _open_capture(self, framerate: float | None = None): if framerate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._cap = cap - self._frame_rate = framerate_to_fraction(framerate) + self._cap: cv2.VideoCapture = cap + self._frame_rate: Fraction = framerate_to_fraction(framerate) self._has_grabbed = False cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 @@ -430,7 +411,6 @@ def capture(self) -> cv2.VideoCapture: backing this object. Using the read/grab methods through this property are unsupported and will leave this object in an inconsistent state. """ - assert self._cap return self._cap # @@ -443,7 +423,6 @@ def capture(self) -> cv2.VideoCapture: @property def frame_rate(self) -> Fraction: """Framerate in frames/sec.""" - assert self._frame_rate return self._frame_rate @property diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index e297600b..057897a6 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -74,8 +74,6 @@ def __init__( VideoOpenFailure: video could not be opened (may be corrupted) ValueError: specified framerate is invalid """ - self._container: av.container.InputContainer | None = None # type: ignore[name-defined] - # TODO(https://scenedetect.com/issues/258): See what # `self._container.discard_corrupt = True` does with corrupt videos. super().__init__() @@ -113,7 +111,7 @@ def __init__( else: self._io = path_or_io - self._container = av.open(self._io) + self._container: av.container.InputContainer = av.open(self._io) # type: ignore[attr-defined] if threading_mode is not None: self._video_stream.thread_type = threading_mode self._reopened = False @@ -145,8 +143,10 @@ def __init__( self._duration_frames = self._get_duration() def __del__(self): - if self._container is not None: - self._container.close() + # `_container` is unset if `__init__` raised before `av.open()` succeeded. + container = getattr(self, "_container", None) + if container is not None: + container.close() # # VideoStream Methods/Properties @@ -273,7 +273,6 @@ def seek(self, target: TimecodeLike) -> None: self._frame = None self._decoder = None self._decode_count = 0 - assert self._container is not None self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -283,7 +282,6 @@ def seek(self, target: TimecodeLike) -> None: def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - assert self._container is not None self._container.close() self._frame = None self._decoder = None @@ -298,7 +296,6 @@ def read(self, decode: bool = True) -> np.ndarray | bool: # B-frame reordering) is never flushed prematurely. Creating a new generator each call # caused the last buffered frame to be lost at EOF. if self._decoder is None: - assert self._container is not None self._decoder = self._container.decode(video=0) try: last_frame = self._frame @@ -322,7 +319,6 @@ def read(self, decode: bool = True) -> np.ndarray | bool: @property def _video_stream(self): """PyAV `av.video.stream.VideoStream` being used.""" - assert self._container is not None return self._container.streams.video[0] @property @@ -379,7 +375,6 @@ def _handle_eof(self): except: self._io.seek(orig_pos) raise - assert self._container is not None self._container.close() self._container = container self._decoder = None From 504046cc2429ed469b8e8b355b332841fa992700 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 13:14:39 -0400 Subject: [PATCH 067/163] [lint] Fix type checking for config parser --- scenedetect/_cli/config.py | 72 ++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 60d4a57a..1154adff 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -152,11 +152,14 @@ class CropValue(ValidatedValue): _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" - def __init__(self, value: str | tuple[int, int, int, int] | None = None): - if isinstance(value, CropValue) or value is None: - self._crop = value + def __init__(self, value: "str | tuple[int, int, int, int] | CropValue | None" = None): + self._crop: tuple[int, int, int, int] | None = None + if isinstance(value, CropValue): + self._crop = value._crop + elif value is None: + return else: - crop = () + crop: tuple[int, ...] = () if isinstance(value, str): translation_table = str.maketrans( {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} @@ -173,11 +176,13 @@ def __init__(self, value: str | tuple[int, int, int, int] | None = None): self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) @property - def value(self) -> tuple[int, int, int, int]: + def value(self) -> tuple[int, int, int, int] | None: return self._crop def __str__(self) -> str: - x0, y0, x1, y1 = self.value + if self._crop is None: + return "(none)" + x0, y0, x1, y1 = self._crop return f"[{x0}, {y0}], [{x1}, {y1}]" @staticmethod @@ -228,25 +233,27 @@ class KernelSizeValue(ValidatedValue): """Validator for kernel sizes (odd integer > 1, or -1 for auto size).""" def __init__(self, value: int): + self._value: int | None if value == -1: - # Downscale factor of -1 maps to None internally for auto downscale. - value = None + # Kernel size of -1 maps to None internally for auto-sized kernel. + self._value = None elif value < 0: # Disallow other negative values. raise ValueError() elif value % 2 == 0: # Disallow even values. raise ValueError() - self._value = value + else: + self._value = value @property - def value(self) -> int: + def value(self) -> int | None: return self._value def __str__(self) -> str: - if self.value is None: + if self._value is None: return "auto" - return str(self.value) + return str(self._value) @staticmethod def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue": @@ -291,7 +298,12 @@ def __init__(self, value: str): @staticmethod def from_config(config_value: str, default: "EscapedString") -> "EscapedChar": - return EscapedString.from_config(config_value, default, length_limit=1) + try: + return EscapedChar(config_value) + except (UnicodeDecodeError, UnicodeEncodeError) as ex: + raise OptionParseFailure( + "Value must be valid UTF-8 string with escape characters." + ) from ex class TimecodeFormat(Enum): @@ -323,7 +335,11 @@ class FcpFormat(Enum): """Final Cut Pro 7 XML Format""" -ConfigValue = bool | int | float | str +# `ConfigValue` covers every concrete type that can appear as a default in +# `CONFIG_MAP` or as a parsed value in `ConfigRegistry._config`. Custom +# validators (`ValidatedValue` subclasses) and `Enum` defaults are included +# because they appear directly in `CONFIG_MAP`. +ConfigValue = bool | int | float | str | None | ValidatedValue | Enum ConfigDict = dict[str, dict[str, ConfigValue]] _CONFIG_FILE_NAME: str = "scenedetect.cfg" @@ -577,26 +593,28 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess config[command] = {} for option in CONFIG_MAP[command]: if command in parser and option in parser[command]: + # Bind to a local so pyright can narrow inside the isinstance branches. + default_value = CONFIG_MAP[command][option] try: value_type = None - if isinstance(CONFIG_MAP[command][option], bool): + if isinstance(default_value, bool): value_type = "yes/no value" config[command][option] = parser.getboolean(command, option) continue - elif isinstance(CONFIG_MAP[command][option], int): + elif isinstance(default_value, int): value_type = "integer" config[command][option] = parser.getint(command, option) continue - elif isinstance(CONFIG_MAP[command][option], float): + elif isinstance(default_value, float): value_type = "number" config[command][option] = parser.getfloat(command, option) continue - elif isinstance(CONFIG_MAP[command][option], Enum): + elif isinstance(default_value, Enum): config_value = ( parser.get(command, option).replace("\n", " ").strip().upper() ) try: - parsed = CONFIG_MAP[command][option].__class__[config_value] + parsed = default_value.__class__[config_value] config[command][option] = parsed except TypeError: success = False @@ -627,12 +645,11 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess # Handle custom validation types. config_value = parser.get(command, option) - default = CONFIG_MAP[command][option] - option_type = type(default) - if issubclass(option_type, ValidatedValue): + if isinstance(default_value, ValidatedValue): + option_type = type(default_value) try: config[command][option] = option_type.from_config( - config_value=config_value, default=default + config_value=config_value, default=default_value ) except OptionParseFailure as ex: success = False @@ -677,7 +694,7 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" - def __init__(self, init_log: tuple[int, str], reason: Exception | None = None): + def __init__(self, init_log: list[LogMessage], reason: Exception | None = None): super().__init__() self.init_log = init_log self.reason = reason @@ -774,16 +791,17 @@ def get_value( annotation. Callers should know the expected type for the option they are reading. """ assert command in CONFIG_MAP and option in CONFIG_MAP[command] + default_value = CONFIG_MAP[command][option] if override is not None: value = override elif command in self._config and option in self._config[command]: value = self._config[command][option] else: - value = CONFIG_MAP[command][option] + value = default_value if isinstance(value, ValidatedValue): return value.value - if isinstance(CONFIG_MAP[command][option], Enum) and isinstance(override, str): - return CONFIG_MAP[command][option].__class__[value.upper().strip()] + if isinstance(default_value, Enum) and isinstance(override, str): + return default_value.__class__[override.upper().strip()] return value def get_help_string(self, command: str, option: str, show_default: bool | None = None) -> str: From 23fc9c5e9ea3287ecede7e5c7e17a6953ed34157 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 13:32:34 -0400 Subject: [PATCH 068/163] [api] Modernize FrameTimecode type hints --- scenedetect/common.py | 140 +++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 64 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index bf40d34f..a4cc31ab 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -179,8 +179,8 @@ class FrameTimecode: def __init__( self, - timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None, - fps: ty.Union[float, "FrameTimecode", Fraction] = None, + timecode: "int | float | str | Timecode | FrameTimecode", + fps: "float | FrameTimecode | Fraction | None" = None, ): """ Arguments: @@ -194,35 +194,19 @@ def __init__( """ self._time: _FrameNumber | _Seconds | Timecode """Internal time representation.""" - self._rate: Fraction = None + self._rate: Fraction | None = None """Rate at which time passes between frames, measured in frames/sec.""" # Copy constructor. if isinstance(timecode, FrameTimecode): - self._rate = timecode._rate if fps is None else fps self._time = timecode._time + self._rate = timecode._rate if fps is None else self._ensure_fractional(fps) return - if not isinstance(fps, (float, Fraction, FrameTimecode)): - raise TypeError("fps must be of type float, Fraction, or FrameTimecode.") - # Ensure args are consistent with API. if fps is None: raise TypeError("fps is a required argument.") - if isinstance(fps, FrameTimecode): - self._rate = fps._rate - elif isinstance(fps, float): - if fps <= MAX_FPS_DELTA: - raise ValueError("Framerate must be positive and greater than zero.") - self._rate = Fraction.from_float(fps) - elif isinstance(fps, Fraction): - if float(fps) <= MAX_FPS_DELTA: - raise ValueError("Framerate must be positive and greater than zero.") - self._rate = fps - else: - raise TypeError( - f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" - ) + self._rate = self._ensure_fractional(fps) # Timecode with a time base. if isinstance(timecode, Timecode): @@ -239,12 +223,12 @@ def __init__( if timecode < 0.0: raise ValueError("Timecode frame number must be positive and greater than zero.") self._time = _Seconds(timecode) - elif isinstance(timecode, int): + else: + # Only `int` remains: `Timecode`/`FrameTimecode` returned earlier and `str`/`float` + # were just handled above. if timecode < 0: raise ValueError("Timecode frame number must be positive and greater than zero.") self._time = _FrameNumber(timecode) - else: - raise TypeError("Timecode format/type unrecognized.") @property def frame_num(self) -> int: @@ -274,6 +258,8 @@ def time_base(self) -> Fraction: """The time base in which presentation time is calculated.""" if isinstance(self._time, Timecode): return self._time.time_base + # `_FrameNumber` / `_Seconds` are only assigned after `_rate` is set. + assert self._rate is not None return 1 / self._rate @property @@ -297,7 +283,7 @@ def get_frames(self) -> int: ) return self.frame_num - def get_framerate(self) -> float: + def get_framerate(self) -> float | None: """[DEPRECATED] Get Framerate: Returns the framerate used by the FrameTimecode object. Use the `framerate` property instead. @@ -332,6 +318,8 @@ def seconds(self) -> float: return self._time.seconds if isinstance(self._time, _Seconds): return self._time.value + # `_FrameNumber` is only assigned after `_rate` is set. + assert self._rate is not None return float(self._time.value / self._rate) def get_seconds(self) -> float: @@ -404,11 +392,31 @@ def get_timecode( # Return hours, minutes, and seconds as a formatted timecode string. return f"{hrs:02d}:{mins:02d}:{secs_str}" + @staticmethod + def _ensure_fractional(fps: "float | FrameTimecode | Fraction") -> Fraction: + """Validate and convert an `fps` argument into a positive `Fraction`.""" + if isinstance(fps, FrameTimecode): + if fps._rate is None: + raise TypeError("FrameTimecode passed as fps must have a known rate.") + return fps._rate + if isinstance(fps, float): + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + return Fraction.from_float(fps) + if isinstance(fps, Fraction): + if float(fps) <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + return fps + raise TypeError( + f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" + ) + def _seconds_to_frames(self, seconds: float) -> int: """Convert `seconds` to the nearest number of frames using the current framerate. *NOTE*: This will not be correct for variable framerate videos. """ + assert self._rate is not None return round(seconds * self._rate) def _parse_timecode_number(self, timecode: int | float) -> int: @@ -450,7 +458,7 @@ def _timecode_to_seconds(self, input: str) -> float: timecode = int(input) if timecode < 0: raise ValueError("Timecode frame number must be positive.") - return timecode / self.framerate + return timecode / float(self._rate) # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") @@ -564,44 +572,46 @@ def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) - def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) + def __iadd__(self, other: "int | float | str | FrameTimecode") -> "FrameTimecode": + # Narrow `other`'s internal time once so pyright can track it through the dispatch below. + other_inner = other._time if isinstance(other, FrameTimecode) else None - if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base == other._time.time_base: + if isinstance(self._time, Timecode) and isinstance(other_inner, Timecode): + if self._time.time_base == other_inner.time_base: self._time = Timecode( - pts=max(0, self._time.pts + other._time.pts), + pts=max(0, self._time.pts + other_inner.pts), time_base=self._time.time_base, ) return self # Different time bases: use the finer (smaller) one for better precision. - time_base = min(self._time.time_base, other._time.time_base) + time_base = min(self._time.time_base, other_inner.time_base) self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) - other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + other_pts = round(Fraction(other_inner.pts) * other_inner.time_base / time_base) self._time = Timecode(pts=max(0, self_pts + other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if isinstance(self._time, Timecode) or other_is_timecode: - timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time - seconds: float = ( - self._get_other_as_seconds(other) - if isinstance(self._time, Timecode) - else self.seconds + if isinstance(self._time, Timecode): + seconds = self._get_other_as_seconds(other) + self._time = Timecode( + pts=max(0, self._time.pts + round(seconds / self._time.time_base)), + time_base=self._time.time_base, ) + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate + return self + if isinstance(other_inner, Timecode): self._time = Timecode( - pts=max(0, timecode.pts + round(seconds / timecode.time_base)), - time_base=timecode.time_base, + pts=max(0, other_inner.pts + round(self.seconds / other_inner.time_base)), + time_base=other_inner.time_base, ) - # Preserve rate if available from self or other. if self._rate is None and isinstance(other, FrameTimecode): self._rate = other._rate return self - other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) - if isinstance(self._time, _Seconds) and other_is_seconds: - self._time = _Seconds(max(0, self._time.value + other._time.value)) + if isinstance(self._time, _Seconds) and isinstance(other_inner, _Seconds): + self._time = _Seconds(max(0.0, self._time.value + other_inner.value)) return self if isinstance(self._time, _Seconds): @@ -616,44 +626,46 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi to_return += other return to_return - def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) + def __isub__(self, other: "int | float | str | FrameTimecode") -> "FrameTimecode": + # Narrow `other`'s internal time once so pyright can track it through the dispatch below. + other_inner = other._time if isinstance(other, FrameTimecode) else None - if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base == other._time.time_base: + if isinstance(self._time, Timecode) and isinstance(other_inner, Timecode): + if self._time.time_base == other_inner.time_base: self._time = Timecode( - pts=max(0, self._time.pts - other._time.pts), + pts=max(0, self._time.pts - other_inner.pts), time_base=self._time.time_base, ) return self # Different time bases: use the finer (smaller) one for better precision. - time_base = min(self._time.time_base, other._time.time_base) + time_base = min(self._time.time_base, other_inner.time_base) self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) - other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + other_pts = round(Fraction(other_inner.pts) * other_inner.time_base / time_base) self._time = Timecode(pts=max(0, self_pts - other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if isinstance(self._time, Timecode) or other_is_timecode: - timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time - seconds: float = ( - self._get_other_as_seconds(other) - if isinstance(self._time, Timecode) - else self.seconds + if isinstance(self._time, Timecode): + seconds = self._get_other_as_seconds(other) + self._time = Timecode( + pts=max(0, self._time.pts - round(seconds / self._time.time_base)), + time_base=self._time.time_base, ) + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate + return self + if isinstance(other_inner, Timecode): self._time = Timecode( - pts=max(0, timecode.pts - round(seconds / timecode.time_base)), - time_base=timecode.time_base, + pts=max(0, other_inner.pts - round(self.seconds / other_inner.time_base)), + time_base=other_inner.time_base, ) - # Preserve rate if available from self or other. if self._rate is None and isinstance(other, FrameTimecode): self._rate = other._rate return self - other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) - if isinstance(self._time, _Seconds) and other_is_seconds: - self._time = _Seconds(max(0, self._time.value - other._time.value)) + if isinstance(self._time, _Seconds) and isinstance(other_inner, _Seconds): + self._time = _Seconds(max(0.0, self._time.value - other_inner.value)) return self if isinstance(self._time, _Seconds): From 006fe37b48fa99fc9b8f2f18cefaca5a1e80a202 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 13:56:37 -0400 Subject: [PATCH 069/163] [api] Strengthen type hints for detectors --- docs/api/migration_guide.rst | 32 ++++++++++++++++++- pyproject.toml | 3 ++ scenedetect/_cli/context.py | 9 ++++-- scenedetect/detector.py | 2 +- scenedetect/detectors/adaptive_detector.py | 4 +++ scenedetect/detectors/content_detector.py | 4 ++- scenedetect/detectors/transnet_v2.py | 2 ++ scenedetect/output/image.py | 6 ++-- scenedetect/scene_manager.py | 4 +-- scenedetect/stats_manager.py | 37 ++++++++++++++-------- tests/test_api.py | 13 ++++---- tests/test_backend_opencv.py | 1 + tests/test_cli.py | 7 ++-- tests/test_detectors.py | 6 ++-- tests/test_platform.py | 2 +- tests/test_scene_manager.py | 8 ++--- tests/test_timecode.py | 7 ++-- tests/test_video_stream.py | 35 +++++++++++++------- website/pages/changelog.md | 6 ++-- 19 files changed, 132 insertions(+), 56 deletions(-) diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index e361a3a6..83c8b7d4 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -160,7 +160,37 @@ All backends now return presentation timestamp (PTS) backed values from ``VideoS ``StatsManager`` Changes ======================================================================= -The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now take a ``FrameTimecode`` instead of ``int`` for the frame identifier, matching the detector interface change. +The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now formally accept either a ``FrameTimecode`` or a plain ``int`` frame number for the timecode argument. Passing a ``FrameTimecode`` is preferred and matches the detector interface; the ``int`` form is retained for compatibility with the deprecated ``load_from_csv()`` path, which keys metrics by integer frame number. + +``StatsManager.load_from_csv()`` also accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str`` / ``bytes`` / file handles. + + +======================================================================= +``SceneDetector`` Annotation Fixes +======================================================================= + +``SceneDetector.post_process()`` now declares its parameter as ``timecode: FrameTimecode`` (previously typed as ``int``). The method already received a ``FrameTimecode`` at runtime and concrete detectors (e.g. ``ThresholdDetector``, ``ContentDetector``) already used the ``FrameTimecode`` type — only the abstract-base-class annotation was inconsistent. No call-site changes are needed; this just brings the signature into agreement with the documented and actual behavior. + + +======================================================================= +``SceneManager.detect_scenes()`` Time Arguments +======================================================================= + +The ``duration`` and ``end_time`` arguments now formally accept ``int`` (frames), ``float`` (seconds), ``str`` (timecode string, e.g. ``"00:00:05.000"``), or ``FrameTimecode``. The internal code already validated these forms; the annotation was previously narrower than the documented behavior. + +.. code:: python + + # All of these were always supported at runtime; now they type-check too: + scene_manager.detect_scenes(video=video, end_time=15.0) # seconds + scene_manager.detect_scenes(video=video, end_time=1500) # frames + scene_manager.detect_scenes(video=video, end_time="00:01:00") # timecode + + +======================================================================= +``save_images()`` Path Handling +======================================================================= + +The ``output_dir`` argument of :func:`scenedetect.output.save_images` now accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str``. No changes are required for existing string-based callers. ======================================================================= diff --git a/pyproject.toml b/pyproject.toml index 35b9e85b..ffec4e3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,9 @@ unfixable = [] [tool.pyright] include = ["scenedetect", "tests"] +# Vendored third-party code: don't lint upstream source (mirrors the ruff +# per-file-ignores convention). +exclude = ["scenedetect/_thirdparty"] # Run pyright from inside an activated venv (or pass `--pythonpath` / # configure your editor's interpreter) so cv2 / av / numpy / moviepy # resolve. Without this, pyright uses its bundled Python and the report diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index cc6eb458..d24df723 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -206,6 +206,7 @@ def handle_options( # The `scenedetect` command was just started, let's initialize logging and try to load any # config files that were specified. + init_log: list = [] try: init_failure = not self.config.initialized init_log = self.config.get_init_log() @@ -305,9 +306,9 @@ def handle_options( scene_manager.auto_downscale = True else: scene_manager.auto_downscale = False - downscale = self.config.get_value("global", "downscale", downscale) + downscale_value: int = self.config.get_value("global", "downscale", downscale) try: - scene_manager.downscale = downscale + scene_manager.downscale = downscale_value except ValueError as ex: logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="downscale factor") from ex @@ -532,11 +533,13 @@ def _open_video_stream( framerate=framerate, backend=backend, ) + duration = self.video_stream.duration + duration_str = f"{duration} ({duration.frame_num} frames)" if duration else "unknown" logger.debug(f"""Video information: Backend: {type(self.video_stream).__name__} Resolution: {self.video_stream.frame_size} Framerate: {self.video_stream.frame_rate} - Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""") + Duration: {duration_str}""") except FrameRateUnavailable as ex: if __debug__: diff --git a/scenedetect/detector.py b/scenedetect/detector.py index ba65b2a6..1d453534 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -61,7 +61,7 @@ def process_frame( # Optional Methods - def post_process(self, timecode: int) -> list[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Called after there are no more frames to process. Args: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 2e634a83..bb4bbd2b 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -100,6 +100,10 @@ def get_metrics(self) -> list[str]: def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: super().process_frame(timecode=timecode, frame_img=frame_img) + # If the parent could not calculate a frame score, there's nothing to buffer. + if self._frame_score is None: + return [] + # Initialize last scene cut point at the beginning of the frames of interest. if self._last_cut is None: self._last_cut = timecode diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 6bdc0eab..730e1ab1 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -169,7 +169,9 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr delta_sat=_mean_pixel_distance(sat, self._last_frame.sat), delta_lum=_mean_pixel_distance(lum, self._last_frame.lum), delta_edges=( - 0.0 if edges is None else _mean_pixel_distance(edges, self._last_frame.edges) + 0.0 + if edges is None or self._last_frame.edges is None + else _mean_pixel_distance(edges, self._last_frame.edges) ), ) diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 83c6260f..088272db 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -107,6 +107,8 @@ def push(self, pixels: np.ndarray, time: np.ndarray): ), ) else: + # `self.time` is set in lockstep with `self.pixels` above, so it is non-None here. + assert self.time is not None c1 = self.pixels c2 = pixels diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 902bcaeb..d38c6fc9 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -28,7 +28,7 @@ Interpolation, SceneList, ) -from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm +from scenedetect.platform import StrPath, get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.video_stream import VideoStream logger = logging.getLogger("pyscenedetect") @@ -162,7 +162,7 @@ def run( self, video: VideoStream, scene_list: SceneList, - output_dir: str | None = None, + output_dir: StrPath | None = None, show_progress=False, ) -> dict[int, list[str]]: """Run image extraction on `video` using the current parameters. Thread-safe. @@ -355,7 +355,7 @@ def save_images( image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: str | None = None, + output_dir: StrPath | None = None, show_progress: bool | None = False, scale: float | None = None, height: int | None = None, diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index b1034e0d..67cd2a8b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -419,8 +419,8 @@ def stop(self) -> None: def detect_scenes( self, video: VideoStream | None = None, - duration: FrameTimecode | None = None, - end_time: FrameTimecode | None = None, + duration: "int | float | str | FrameTimecode | None" = None, + end_time: "int | float | str | FrameTimecode | None" = None, frame_skip: int = 0, show_progress: bool = False, callback: ty.Callable[[np.ndarray, FrameTimecode], None] | None = None, diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index d2292f67..cbdb8602 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -96,19 +96,22 @@ class StatsManager: Only metrics consisting of `float` or `int` should be used currently. """ - def __init__(self, base_timecode: FrameTimecode | None = None): + def __init__(self, base_timecode: int | FrameTimecode | None = None): """Initialize a new StatsManager. Arguments: base_timecode: Timecode associated with this object. Must not be None (default value will be removed in a future release). """ - # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) - # of each frame metric key and the value it represents (usually float). - self._frame_metrics: dict[FrameTimecode, dict[str, float]] = dict() + # Frame metrics keyed by either an `int` frame number or a `FrameTimecode`. Both forms + # hash/compare to the same dict slot (`FrameTimecode.__hash__` returns `frame_num`), so + # public methods accept both interchangeably for the same frame. + self._frame_metrics: dict[int | FrameTimecode, dict[str, float]] = dict() self._metric_keys: set[str] = set() self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: FrameTimecode | None = base_timecode # Used for timing calculations. + self._base_timecode: int | FrameTimecode | None = ( + base_timecode # Used for timing calculations. + ) @property def metric_keys(self) -> ty.Iterable[str]: @@ -120,7 +123,9 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: # TODO(https://scenedetect.com/issues/507): We should support the dictionary protocol instead # of using this bespoke interface. It would be useful for Pandas compatibility as well. - def get_metrics(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> list[ty.Any]: + def get_metrics( + self, timecode: int | FrameTimecode, metric_keys: ty.Iterable[str] + ) -> list[ty.Any]: """Return the requested statistics/metrics for a given timecode. Returns: @@ -129,7 +134,7 @@ def get_metrics(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> """ return [self._get_metric(timecode, metric_key) for metric_key in metric_keys] - def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: + def set_metrics(self, timecode: int | FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: @@ -139,7 +144,7 @@ def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: dict[str, ty.Any] for metric_key in metric_kv_dict: self._set_metric(timecode, metric_key, metric_kv_dict[metric_key]) - def metrics_exist(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> bool: + def metrics_exist(self, timecode: int | FrameTimecode, metric_keys: ty.Iterable[str]) -> bool: """Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: @@ -188,6 +193,10 @@ def save_to_csv( frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: + # `frame_key` may be a bare `int` if the deprecated `load_from_csv` populated the dict. + # Skip such rows since we cannot recover a timecode without a base framerate. + if not isinstance(frame_key, FrameTimecode): + continue csv_writer.writerow( [frame_key.frame_num + 1, frame_key.get_timecode()] + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] @@ -209,7 +218,7 @@ def valid_header(row: list[str]) -> bool: # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. - def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: + def load_from_csv(self, csv_file: StrPath | bytes | ty.TextIO) -> int | None: """[DEPRECATED] DO NOT USE Load all metrics stored in a CSV file into the StatsManager instance. Will be removed in a @@ -233,7 +242,7 @@ def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: # If we get a path instead of an open file handle, check that it exists, and if so, # recursively call ourselves again but with file set instead of path. - if isinstance(csv_file, (str, bytes, Path)): + if isinstance(csv_file, (str, bytes, os.PathLike)): if os.path.exists(csv_file): with open(csv_file) as file: return self.load_from_csv(csv_file=file) @@ -288,16 +297,18 @@ def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Any | None: + def _get_metric(self, timecode: int | FrameTimecode, metric_key: str) -> ty.Any | None: if self._metric_exists(timecode, metric_key): return self._frame_metrics[timecode][metric_key] return None - def _set_metric(self, timecode: FrameTimecode, metric_key: str, metric_value: ty.Any) -> None: + def _set_metric( + self, timecode: int | FrameTimecode, metric_key: str, metric_value: ty.Any + ) -> None: self._metrics_updated = True if timecode not in self._frame_metrics: self._frame_metrics[timecode] = dict() self._frame_metrics[timecode][metric_key] = metric_value - def _metric_exists(self, timecode: FrameTimecode, metric_key: str) -> bool: + def _metric_exists(self, timecode: int | FrameTimecode, metric_key: str) -> bool: return timecode in self._frame_metrics and metric_key in self._frame_metrics[timecode] diff --git a/tests/test_api.py b/tests/test_api.py index 4d355ab8..7a3369cc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -101,6 +101,7 @@ def test_api_stats_manager(test_video_file: str): scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. filename = f"{test_video_file}.stats.csv" + assert scene_manager.stats_manager is not None scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -108,11 +109,11 @@ def test_api_scene_manager_callback(test_video_file: str): """Demonstrate how to use a callback with the SceneManager detect_scenes method.""" import numpy - from scenedetect import ContentDetector, SceneManager, open_video + from scenedetect import ContentDetector, FrameTimecode, SceneManager, open_video # Callback to invoke on the first frame of every new scene detection. - def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print(f"New scene found at frame {frame_num}.") + def on_new_scene(frame_img: numpy.ndarray, position: FrameTimecode): + print(f"New scene found at frame {position.frame_num}.") video = open_video(test_video_file) scene_manager = SceneManager() @@ -127,11 +128,11 @@ def test_api_device_callback(test_video_file: str): import cv2 import numpy - from scenedetect import ContentDetector, SceneManager, VideoCaptureAdapter + from scenedetect import ContentDetector, FrameTimecode, SceneManager, VideoCaptureAdapter # Callback to invoke on the first frame of every new scene detection. - def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print(f"New scene found at frame {frame_num}.") + def on_new_scene(frame_img: numpy.ndarray, position: FrameTimecode): + print(f"New scene found at frame {position.frame_num}.") # We open a file just for test purposes, but we can also use a device or pipe here. cap = cv2.VideoCapture(test_video_file) diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index d1d66020..557de35a 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -31,6 +31,7 @@ def test_open_image_sequence(test_image_sequence: str): sequence = VideoStreamCv2(test_image_sequence, framerate=25.0) assert sequence.is_seekable assert sequence.frame_size[0] > 0 and sequence.frame_size[1] > 0 + assert sequence.duration is not None assert sequence.duration.frame_num == 30 assert sequence.read() is not False sequence.seek(100) diff --git a/tests/test_cli.py b/tests/test_cli.py index 05044ff0..9e77af70 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,6 +37,7 @@ import scenedetect from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available +from scenedetect.platform import StrPath from tests.helpers import invoke_cli SCENEDETECT_CMD = sys.executable + " -m scenedetect" @@ -66,7 +67,7 @@ def invoke_scenedetect( args: str = "", - output_dir: str | None = None, + output_dir: StrPath | None = None, config_file: str | None = DEFAULT_CONFIG_FILE, **kwargs, ): @@ -536,7 +537,7 @@ def test_cli_save_images(tmp_path: Path): # Should detect two scenes and generate 3 images per scene with above params. assert len(images) == 6 # Open one of the created images and make sure it has the correct resolution. - image = cv2.imread(images[0]) + image = cv2.imread(str(images[0])) assert image.shape == (544, 1280, 3) @@ -575,7 +576,7 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): images = [image for image in tmp_path.glob("*.jpg")] # Should detect two scenes and generate 3 images per scene with above params. assert len(images) == 6 - image = cv2.imread(images[0]) + image = cv2.imread(str(images[0])) # Note same resolution as in test_cli_save_images but rotated 90 degrees. assert image.shape == (1280, 544, 3) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 638479b5..e6c62a72 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -31,14 +31,16 @@ ThresholdDetector, ) -FAST_CUT_DETECTORS: tuple[type[SceneDetector]] = ( +# Untyped so each entry retains its concrete `type[…]` for parameterized construction +# (calls below pass detector-specific kwargs like `min_scene_len`). +FAST_CUT_DETECTORS = ( AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ) -ALL_DETECTORS: tuple[type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) +ALL_DETECTORS = (*FAST_CUT_DETECTORS, ThresholdDetector) # TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively # consistent frame scores regardless of resolution. This will ensure that threshold values will hold diff --git a/tests/test_platform.py b/tests/test_platform.py index 319a54ea..50130518 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -37,4 +37,4 @@ def test_long_command(): """ if platform.system() == "Windows": with pytest.raises(CommandTooLong): - invoke_command("x" * 2**15) + invoke_command(["x" * 2**15]) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 7a78813f..0ba91fc2 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -198,15 +198,15 @@ def test_detect_scenes_crop(test_video_file): def test_crop_invalid(): sm = SceneManager() - sm.crop = None + sm.crop = None # type: ignore[assignment] sm.crop = (0, 0, 0, 0) sm.crop = (1, 1, 0, 0) sm.crop = (0, 0, 1, 1) with pytest.raises(TypeError): - sm.crop = 1 + sm.crop = 1 # type: ignore[assignment] with pytest.raises(TypeError): - sm.crop = (1, 1) + sm.crop = (1, 1) # type: ignore[assignment] with pytest.raises(TypeError): - sm.crop = (1, 1, 1) + sm.crop = (1, 1, 1) # type: ignore[assignment] with pytest.raises(ValueError): sm.crop = (1, 1, 1, -1) diff --git a/tests/test_timecode.py b/tests/test_timecode.py index f4296923..0ad4be52 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -33,11 +33,14 @@ def test_framerate(): """Test FrameTimecode constructor argument "fps".""" # Not passing fps results in TypeError. with pytest.raises(TypeError): - FrameTimecode() + FrameTimecode() # type: ignore[call-arg] with pytest.raises(TypeError): FrameTimecode(timecode=0, fps=None) with pytest.raises(TypeError): - FrameTimecode(timecode=None, fps=FrameTimecode(timecode=0, fps=None)) + FrameTimecode( + timecode=None, # type: ignore[arg-type] + fps=FrameTimecode(timecode=0, fps=None), + ) # Test zero FPS/negative. with pytest.raises(ValueError): FrameTimecode(timecode=0, fps=0.0) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index e0903832..0930c155 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -17,6 +17,7 @@ """ import os.path +import typing as ty from dataclasses import dataclass import numpy @@ -138,11 +139,12 @@ def get_test_video_params() -> list[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_properties(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) + assert stream.duration is not None assert stream.duration.frame_num == test_video.total_frames file_name = os.path.basename(test_video.path) last_dot_pos = file_name.rfind(".") @@ -151,21 +153,26 @@ def test_properties(self, vs_type: type[VideoStream], test_video: VideoParameter test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_read(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" stream = vs_type(test_video.path) frame = stream.read() + assert isinstance(frame, numpy.ndarray) # For now hard-code 3 channels/pixel for each test video assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_decode(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_read_no_decode( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_time_invariants( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -188,7 +195,7 @@ def test_time_invariants(self, vs_type: type[VideoStream], test_video: VideoPara 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_reset(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" stream = vs_type(test_video.path) # Decode some frames, then reset the VideoStream and validate the time invariants. @@ -200,7 +207,7 @@ def test_reset(self, vs_type: type[VideoStream], test_video: VideoParameters): assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate `seek()` functionality with different offset types.""" stream = vs_type(test_video.path) @@ -246,7 +253,7 @@ def test_seek(self, vs_type: type[VideoStream], test_video: VideoParameters): assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek_start(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" stream = vs_type(test_video.path) # Here we check similar invariants to test_time_invariants, but using seek(). @@ -281,7 +288,7 @@ def test_seek_start(self, vs_type: type[VideoStream], test_video: VideoParameter assert stream.frame_number == 2 stream = vs_type(test_video.path) - def test_read_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_read_eof(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Ensure calling `read()` handles the end of the video correctly.""" stream = vs_type(test_video.path) # To make the test faster, we seek to the second last frame. @@ -294,7 +301,9 @@ def test_read_eof(self, vs_type: type[VideoStream], test_video: VideoParameters) else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Validate calling `seek()` to offset past end of video.""" stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit @@ -313,7 +322,9 @@ def test_seek_past_eof(self, vs_type: type[VideoStream], test_video: VideoParame else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek_invalid( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -329,13 +340,13 @@ def test_seek_invalid(self, vs_type: type[VideoStream], test_video: VideoParamet # -def test_invalid_path(vs_type: type[VideoStream]): +def test_invalid_path(vs_type: ty.Callable[..., VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): _ = vs_type("this_path_should_not_exist.mp4") -def test_corrupt_video(vs_type: type[VideoStream], corrupt_video_file: str): +def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6f6acf7f..e44697de 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -698,8 +698,10 @@ Although there have been minimal changes to most API examples, there are several **Detector Interface:** * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface [#168](https://github.com/Breakthrough/PySceneDetect/issues/168): - * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` - * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` + * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` (the `post_process` signature on the abstract base is now consistently typed as `FrameTimecode` to match its concrete-detector overrides; the prior `int` annotation did not reflect the actual runtime value) + * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` formally accept either `FrameTimecode` or `int` (the `int` form is retained for compatibility with the deprecated `load_from_csv()` path, which keys metrics by integer frame number) + * `StatsManager.load_from_csv()` and `save_images()` `output_dir` now accept `os.PathLike` (e.g. `pathlib.Path`) in addition to `str` + * `SceneManager.detect_scenes()` `duration` and `end_time` formally accept `int` (frames), `float` (seconds), `str` (timecode), or `FrameTimecode` — matching the documented and runtime-supported behavior * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called * Remove `SceneDetector.is_processing_required()` method From 2c1273688ce951710e5e5695e000b49467c69e65 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 14:21:36 -0400 Subject: [PATCH 070/163] [lint] Remove pyright suppressions, code is now clean at basic --- pyproject.toml | 48 ++++++++++++++---------------------------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffec4e3d..90b895ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,38 +61,18 @@ unfixable = [] [tool.pyright] include = ["scenedetect", "tests"] -# Vendored third-party code: don't lint upstream source (mirrors the ruff -# per-file-ignores convention). -exclude = ["scenedetect/_thirdparty"] -# Run pyright from inside an activated venv (or pass `--pythonpath` / -# configure your editor's interpreter) so cv2 / av / numpy / moviepy -# resolve. Without this, pyright uses its bundled Python and the report -# fills with cascading "import could not be resolved" noise. -# Per-developer venv paths are deliberately NOT pinned here. -# -# Modes: "off" | "basic" | "standard" | "strict". -# We're at "basic" for 0.7. Bumping requires the cleanup in TODO(0.8) below. -typeCheckingMode = "basic" - -# Third-party noise: cv2, av, moviepy ship without (or with partial) type -# stubs; these reports are unactionable in this codebase. -reportMissingTypeStubs = "none" -reportUnknownMemberType = "none" -reportUnknownArgumentType = "none" -reportUnknownVariableType = "none" -reportUnknownParameterType = "none" - -# Click + pytest decorators are conventionally untyped. -reportUntypedFunctionDecorator = "none" +exclude = [ + # Pyright built-in defaults + "**/node_modules", + "**/__pycache__", + "**/.*", + ".venv", + # Vendored third-party code + "scenedetect/_thirdparty", +] -# TODO(0.8): Audit the rules below. They were added globally with a -# third-party justification, but they affect the whole codebase. After the -# remaining first-party type-debt is cleared (FrameTimecode/TimecodeLike in -# common.py, _cli/config.py CropValue overloads, Click CLI typing in -# _cli/__init__.py), re-enable each in isolation and decide keep/drop: -# reportMissingParameterType — first-party coverage looks complete -# reportMissingTypeArgument — modern Python defaults absorb most -# reportPrivateUsage — no cross-module _private access seen -reportMissingParameterType = "none" -reportMissingTypeArgument = "none" -reportPrivateUsage = "none" +# Modes: "off" | "basic" | "standard" | "strict". The 0.7 codebase is clean +# at "basic". When tightening to "standard" or "strict" in the future, the +# cv2 / av / numpy / moviepy noise rules (reportUnknown*, reportMissingTypeStubs) +# will likely need to be re-added with "none" or scoped per-file. +typeCheckingMode = "basic" From 1691c6d2ba0a90494290730e3d1ea8e0533304af Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 18:04:14 -0400 Subject: [PATCH 071/163] [build] Extend action paths to cover project/lint config changes --- .github/workflows/build-windows.yml | 6 ++++++ .github/workflows/build.yml | 4 ++++ .github/workflows/check-code-format.yml | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2f788f99..86603d0a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -10,11 +10,17 @@ on: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt + - .github/workflows/build-windows.yml push: paths: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt + - .github/workflows/build-windows.yml branches: - main - 'releases/**' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8ce7bdb..e7c717b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,8 @@ on: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt - .github/workflows/build.yml - .github/actions/setup-ffmpeg/** push: @@ -16,6 +18,8 @@ on: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt - .github/workflows/build.yml - .github/actions/setup-ffmpeg/** branches: diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index 1cb694c2..d0dbac6e 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -6,10 +6,16 @@ on: paths: - scenedetect/** - tests/** + - pyproject.toml + - .style.yapf + - .github/workflows/check-code-format.yml push: paths: - scenedetect/** - tests/** + - pyproject.toml + - .style.yapf + - .github/workflows/check-code-format.yml jobs: check_format: From dab8b2651768af9fc96754658c86dcc5de9ffc7e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 18:23:35 -0400 Subject: [PATCH 072/163] [feat] Add start TC shift to save-edl functionality #515 --- scenedetect/_cli/__init__.py | 14 ++++++ scenedetect/_cli/commands.py | 2 + scenedetect/_cli/config.py | 1 + scenedetect/output/__init__.py | 38 ++++++++++++++++- tests/test_output.py | 78 ++++++++++++++++++++++++++++++++++ website/pages/changelog.md | 2 + 6 files changed, 133 insertions(+), 2 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 16dfce5b..5613c0ee 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1597,6 +1597,18 @@ def save_images_command( USER_CONFIG.get_help_string("save-edl", "output", show_default=False) ), ) +@click.option( + "--start-timecode", + "-s", + metavar="TIMECODE", + default=None, + type=click.STRING, + help=( + "Start timecode added to every event so the EDL aligns with the source media's " + "on-screen timecode. Accepts SMPTE HH:MM:SS:FF or 8 digits (HHMMSSFF, e.g. 01000000)." + "{}" + ).format(USER_CONFIG.get_help_string("save-edl", "start-timecode", show_default=False)), +) @click.pass_context def save_edl_command( ctx: click.Context, @@ -1604,6 +1616,7 @@ def save_edl_command( title: str | None, reel: str | None, output: str | None, + start_timecode: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1613,6 +1626,7 @@ def save_edl_command( "title": ctx.config.get_value("save-edl", "title", title), "reel": ctx.config.get_value("save-edl", "reel", reel), "output": ctx.config.get_value("save-edl", "output", output), + "start_timecode": ctx.config.get_value("save-edl", "start-timecode", start_timecode), } ctx.add_command(cli_commands.save_edl, save_edl_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index f87d97be..35400003 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -263,6 +263,7 @@ def save_edl( output: str, title: str, reel: str, + start_timecode: str | None, ): """Handles the `save-edl` command. Outputs in CMX 3600 format.""" del cuts # We only use scene information. @@ -277,6 +278,7 @@ def save_edl( scene_list=scenes, title=Template(title).safe_substitute(VIDEO_NAME=video_name), reel=reel, + start_timecode=start_timecode, ) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 1154adff..81acc236 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -424,6 +424,7 @@ class FcpFormat(Enum): "filename": "$VIDEO_NAME.edl", "output": None, "reel": "AX", + "start-timecode": None, "title": "$VIDEO_NAME", }, "save-html": { diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index ceff96c3..e4c38a2e 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -18,6 +18,7 @@ import csv import json import logging +import math import typing as ty from fractions import Fraction from pathlib import Path @@ -253,11 +254,36 @@ def _edl_timecode(timecode: FrameTimecode) -> str: return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" +def _parse_edl_start_timecode(value: str, framerate: Fraction | float) -> int: + """Parse a SMPTE ``HH:MM:SS:FF`` (or 8-digit ``HHMMSSFF``) start timecode into a frame count.""" + stripped = value.strip() + if ":" in stripped: + parts = stripped.split(":") + elif stripped.isdigit() and len(stripped) == 8: + parts = [stripped[0:2], stripped[2:4], stripped[4:6], stripped[6:8]] + else: + raise ValueError( + f"Invalid start timecode {value!r}: expected HH:MM:SS:FF or 8 digits (HHMMSSFF)." + ) + if len(parts) != 4 or not all(p.isdigit() for p in parts): + raise ValueError( + f"Invalid start timecode {value!r}: expected HH:MM:SS:FF or 8 digits (HHMMSSFF)." + ) + hours, minutes, seconds, frames = (int(p) for p in parts) + max_frames = math.ceil(float(framerate)) + if minutes >= 60 or seconds >= 60 or frames >= max_frames: + raise ValueError( + f"Invalid start timecode {value!r}: MM<60, SS<60, FF<{max_frames} required." + ) + return round((hours * 3600 + minutes * 60 + seconds) * float(framerate)) + frames + + def write_scene_list_edl( output_path: str | Path, scene_list: SceneList, title: str = "PySceneDetect", reel: str = "AX", + start_timecode: str | None = None, ): """Writes the given list of scenes to `output_path` in CMX 3600 EDL format. @@ -266,12 +292,20 @@ def write_scene_list_edl( scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end. title: Title header written as ``TITLE:`` in the EDL. reel: Reel name used for each event. Typically 2-8 uppercase characters. + start_timecode: Optional SMPTE timecode (``HH:MM:SS:FF`` or 8-digit ``HHMMSSFF``) added to + every event so the EDL aligns with the source media's on-screen timecode. Applied to + both source and record columns. """ output_path = Path(output_path) + offset_frames = 0 + if start_timecode is not None and start_timecode.strip() and scene_list: + framerate = scene_list[0][0].framerate + assert framerate is not None + offset_frames = _parse_edl_start_timecode(start_timecode, framerate) lines = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""] for i, (start, end) in enumerate(scene_list): - in_tc = _edl_timecode(start) - out_tc = _edl_timecode(end) + in_tc = _edl_timecode(start + offset_frames) + out_tc = _edl_timecode(end + offset_frames) lines.append(f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}") logger.info("Writing scenes in EDL format to %s", output_path) with open(output_path, "w") as f: diff --git a/tests/test_output.py b/tests/test_output.py index d3efe182..42b93c18 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -269,6 +269,84 @@ def test_write_scene_list_edl_accepts_str_path(tmp_path: Path): assert output_path.exists() +def test_write_scene_list_edl_with_start_timecode_smpte(tmp_path: Path): + """`start_timecode` shifts every event by the supplied SMPTE offset (source + record).""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, start_timecode="01:00:00:00") + + content = output_path.read_text() + assert "001 AX V C 01:00:00:00 01:00:01:00 01:00:00:00 01:00:01:00" in content + assert "002 AX V C 01:00:01:00 01:00:02:00 01:00:01:00 01:00:02:00" in content + + +def test_write_scene_list_edl_with_start_timecode_digits(tmp_path: Path): + """8-digit form (numpad-friendly) yields the same output as the colon-separated form.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + smpte_path = tmp_path / "smpte.edl" + digits_path = tmp_path / "digits.edl" + write_scene_list_edl(smpte_path, scenes, start_timecode="01:00:00:00") + write_scene_list_edl(digits_path, scenes, start_timecode="01000000") + + assert smpte_path.read_text() == digits_path.read_text() + + +def test_write_scene_list_edl_with_start_timecode_subsecond(tmp_path: Path): + """A sub-second frame offset (FF component) is added to every event.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, start_timecode="00:00:00:15") + + content = output_path.read_text() + assert "001 AX V C 00:00:00:15 00:00:01:15 00:00:00:15 00:00:01:15" in content + + +def test_write_scene_list_edl_default_no_offset(tmp_path: Path): + """Omitting `start_timecode` (or passing ``None``/empty) preserves the existing baseline.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + baseline = tmp_path / "baseline.edl" + explicit_none = tmp_path / "none.edl" + explicit_empty = tmp_path / "empty.edl" + write_scene_list_edl(baseline, scenes) + write_scene_list_edl(explicit_none, scenes, start_timecode=None) + write_scene_list_edl(explicit_empty, scenes, start_timecode=" ") + + assert baseline.read_text() == explicit_none.read_text() == explicit_empty.read_text() + + +@pytest.mark.parametrize( + "bad_value", + [ + "bogus", + "00:00:00", # 3 segments, not 4 + "00:00:00:00:00", # 5 segments + "1234567", # 7 digits + "123456789", # 9 digits + "ab:cd:ef:gh", # non-numeric + ], +) +def test_write_scene_list_edl_with_start_timecode_invalid_format(tmp_path: Path, bad_value: str): + """Malformed start timecodes raise ValueError before writing.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + with pytest.raises(ValueError): + write_scene_list_edl(tmp_path / "scenes.edl", scenes, start_timecode=bad_value) + + +@pytest.mark.parametrize( + "bad_value", + [ + "00:60:00:00", # MM=60 + "00:00:60:00", # SS=60 + "00:00:00:99", # FF beyond ceil(30 fps) + ], +) +def test_write_scene_list_edl_with_start_timecode_out_of_range(tmp_path: Path, bad_value: str): + """Out-of-range SMPTE components raise ValueError.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + with pytest.raises(ValueError): + write_scene_list_edl(tmp_path / "scenes.edl", scenes, start_timecode=bad_value) + + def test_write_scene_list_fcpx(tmp_path: Path): """FCPXML output declares version 1.9, rational time strings, and an asset-clip per scene.""" scenes = _fake_scenes(_FPS_NTSC, [(48, 96), (96, 144)]) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e44697de..f38e16ce 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -678,6 +678,7 @@ Although there have been minimal changes to most API examples, there are several - [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters - [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) +- [feature] `save-edl` accepts a new `--start-timecode`/`-s` flag (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) to stamp every event with a custom start timecode so generated EDLs align with the source media's on-screen timecode [#515](https://github.com/Breakthrough/PySceneDetect/issues/515) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496) - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command @@ -687,6 +688,7 @@ Although there have been minimal changes to most API examples, there are several **VFR & Timestamp Overhaul:** * Add `write_scene_list_edl`, `write_scene_list_fcpx`, `write_scene_list_fcp7`, and `write_scene_list_otio` to the `scenedetect.output` module so `save-edl`, `save-fcp`, and `save-otio` can be invoked directly from Python (previously CLI-only) + * `write_scene_list_edl` accepts an optional `start_timecode` parameter (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) that is added to every event's source and record columns [#515](https://github.com/Breakthrough/PySceneDetect/issues/515) * Add new `Timecode` type to represent frame timings in terms of the video's source timebase * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` From 97f1e4b3b87a0d2c1102613c116b3dca2f97ca00 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 18:30:43 -0400 Subject: [PATCH 073/163] [general] Change copyright style to single year, files use first date of creation now --- LICENSE | 2 +- README.md | 2 +- dist/package-info.rst | 2 +- dist/pre_release.py | 2 +- docs/conf.py | 2 +- docs/generate_cli_docs.py | 2 +- docs/index.rst | 2 +- pyproject.toml | 2 +- scenedetect/__init__.py | 2 +- scenedetect/__main__.py | 2 +- scenedetect/_cli/__init__.py | 4 ++-- scenedetect/_cli/commands.py | 2 +- scenedetect/_cli/config.py | 2 +- scenedetect/_cli/context.py | 2 +- scenedetect/_cli/controller.py | 2 +- scenedetect/_thirdparty/__init__.py | 2 +- scenedetect/backends/__init__.py | 2 +- scenedetect/backends/moviepy.py | 2 +- scenedetect/backends/opencv.py | 2 +- scenedetect/backends/pyav.py | 2 +- scenedetect/common.py | 2 +- scenedetect/detector.py | 2 +- scenedetect/detectors/__init__.py | 2 +- scenedetect/detectors/adaptive_detector.py | 2 +- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/hash_detector.py | 2 +- scenedetect/detectors/histogram_detector.py | 2 +- scenedetect/detectors/threshold_detector.py | 2 +- scenedetect/detectors/transnet_v2.py | 2 +- scenedetect/frame_timecode.py | 2 +- scenedetect/output/__init__.py | 2 +- scenedetect/output/image.py | 2 +- scenedetect/output/video.py | 2 +- scenedetect/platform.py | 2 +- scenedetect/scene_detector.py | 2 +- scenedetect/scene_manager.py | 2 +- scenedetect/stats_manager.py | 2 +- scenedetect/video_splitter.py | 2 +- scenedetect/video_stream.py | 2 +- setup.py | 2 +- tests/__init__.py | 2 +- tests/conftest.py | 2 +- tests/helpers.py | 2 +- tests/test_api.py | 2 +- tests/test_backend_opencv.py | 2 +- tests/test_backend_pyav.py | 2 +- tests/test_cli.py | 2 +- tests/test_detectors.py | 2 +- tests/test_output.py | 2 +- tests/test_platform.py | 2 +- tests/test_stats_manager.py | 2 +- tests/test_timecode.py | 2 +- tests/test_vfr.py | 2 +- tests/test_video_stream.py | 2 +- website/mkdocs.yml | 4 ++-- website/pages/copyright.md | 2 +- 56 files changed, 58 insertions(+), 58 deletions(-) diff --git a/LICENSE b/LICENSE index b8110e2f..c03985e6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (C) 2024, Brandon Castellano +Copyright (C) 2014, Brandon Castellano Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 2855638a..6f1a5a18 100644 --- a/README.md +++ b/README.md @@ -125,5 +125,5 @@ BSD-3-Clause; see [`LICENSE`](LICENSE) and [`THIRD-PARTY.md`](THIRD-PARTY.md) fo ---------------------------------------------------------- -Copyright (C) 2014-2024 Brandon Castellano. +Copyright (C) 2014 Brandon Castellano. All rights reserved. diff --git a/dist/package-info.rst b/dist/package-info.rst index 94fdfc8e..09a56570 100644 --- a/dist/package-info.rst +++ b/dist/package-info.rst @@ -43,6 +43,6 @@ You can also use the Python API (`docs . +# Copyright (C) 2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/docs/conf.py b/docs/conf.py index a7935b56..954c2552 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,7 @@ # -- Project information ----------------------------------------------------- project = "PySceneDetect" -copyright = "2014-2024, Brandon Castellano" +copyright = "2014, Brandon Castellano" author = "Brandon Castellano" # The short X.Y version diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index f26ae114..7e1cd4dd 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -2,7 +2,7 @@ # # Inspired by sphinx-click: https://github.com/click-contrib/sphinx-click # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. """Generates CLI reference documentation file docs/cli.rst. diff --git a/docs/index.rst b/docs/index.rst index f3de773a..b258fb21 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ .. PySceneDetect documentation index file (contains toctree directive). - Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. + Copyright (C) 2014 Brandon Castellano. All rights reserved. ####################################################################### PySceneDetect Documentation diff --git a/pyproject.toml b/pyproject.toml index 90b895ee..32d1d102 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # [ Documentation: http://www.scenedetect.com/docs/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . # [build-system] diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 19460d7c..afd43ab0 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2016 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 5a8f7e4c..bd4754ab 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2016 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 5613c0ee..84e2d388 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -73,7 +73,7 @@ def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": Docs: https://www.scenedetect.com/docs/ Code: https://github.com/Breakthrough/PySceneDetect/ -Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. +Copyright (C) 2014 Brandon Castellano. All rights reserved. PySceneDetect is released under the BSD 3-Clause license. See the LICENSE file or visit [ https://www.scenedetect.com/copyright/ ]. diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 35400003..b3eca646 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 81acc236..3e5cfdda 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index d24df723..73c30afc 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index aa43a45e..dc8008f2 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_thirdparty/__init__.py b/scenedetect/_thirdparty/__init__.py index 5442893f..c7e79af1 100644 --- a/scenedetect/_thirdparty/__init__.py +++ b/scenedetect/_thirdparty/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index ae2059c5..dfe8ff7a 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index ad8a849a..84039e3a 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index fb64585e..b2369064 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 057897a6..edb77628 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/common.py b/scenedetect/common.py index a4cc31ab..795c42bc 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 1d453534..f5499f46 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 70eb5eb7..565ac354 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index bb4bbd2b..71c518b1 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2021 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 730e1ab1..06754669 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 40b4e3c8..9fb7b25c 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2022 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 408a8312..2d2439e6 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -5,7 +5,7 @@ # [ Docs: http://manual.scenedetect.scenedetect.com/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2022 Brandon Castellano . +# Copyright (C) 2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index dd8d2a8a..3a7db315 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 088272db..e5cce4c4 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2026 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 8411cef0..8fcb5e15 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index e4c38a2e..7fb6f17a 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index d38c6fc9..102298c5 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 35f8841e..c3a0b4cf 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 0c41cd50..89c0d81a 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2016 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 5de13977..fed33b97 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 67cd2a8b..17eaaeb8 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index cbdb8602..dc415f3c 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 2b8499da..563ea269 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 3c3687a1..95da5d5e 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/setup.py b/setup.py index ec281380..a91bbda1 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # [ Documentation: http://www.scenedetect.com/docs/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . # """PySceneDetect setup.py - DEPRECATED. diff --git a/tests/__init__.py b/tests/__init__.py index 981ec4b7..c616990f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/conftest.py b/tests/conftest.py index 21a43eb8..a8823249 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2020 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/helpers.py b/tests/helpers.py index 87829cb9..dc2d6362 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2026 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_api.py b/tests/test_api.py index 7a3369cc..5a0e7056 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index 557de35a..3778d811 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index 8e27a495..5f658ac6 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_cli.py b/tests/test_cli.py index 9e77af70..504ac1dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_detectors.py b/tests/test_detectors.py index e6c62a72..6ba9d263 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2021 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_output.py b/tests/test_output.py index 42b93c18..c60cc353 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_platform.py b/tests/test_platform.py index 50130518..e4b3fe18 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2020 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 03bef2c7..5e7360ae 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 0ad4be52..42d4a082 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 3189ee5d..6ec0c2e8 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2026 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 0930c155..9b8e3610 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 71ed0b0b..4429a64e 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -1,5 +1,5 @@ # PySceneDetect Website (https://www.scenedetect.com) -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . site_name: PySceneDetect site_description: "Website and documentation for PySceneDetect, a program to automatically detect scene cuts and split videos. Written in Python, and also provides Python API in addition to command-line interface for use within other programs." site_author: "Brandon Castellano" @@ -8,7 +8,7 @@ site_dir: "build" repo_url: https://github.com/Breakthrough/PySceneDetect edit_uri: 'blob/main/website/pages/' repo_name: "PySceneDetect on Github" -copyright: 'Copyright © 2014-2024 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' +copyright: 'Copyright © 2014 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' theme: name: readthedocs logo: img/pyscenedetect_logo_small.png diff --git a/website/pages/copyright.md b/website/pages/copyright.md index b88f7f22..b849e501 100644 --- a/website/pages/copyright.md +++ b/website/pages/copyright.md @@ -6,7 +6,7 @@ PySceneDetect License (BSD 3-Clause) < http://www.bcastell.com/projects/PySceneDetect > -Copyright (C) 2014-2024, Brandon Castellano. +Copyright (C) 2014, Brandon Castellano. All rights reserved. Redistribution and use in source and binary forms, with or without From 63fbf8b76d1c1bdb405c62a5353b2ba3395afa47 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 19:02:27 -0400 Subject: [PATCH 074/163] [build] Update jinja2 --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index 8455efc2..f2cfb9c8 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.1.5 +jinja2>=3.1.6 From 2726346cb9982c4d082b6aa09a281c0b7c9bec83 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 19:11:46 -0400 Subject: [PATCH 075/163] [release] Add new release test suite, workflow runner, and release plan template --- .github/workflows/release-test.yml | 144 ++++++++++++++++++++++ RELEASE-PLAN.md | 75 ++++++++++++ pyproject.toml | 6 + scripts/generate_goldens.py | 90 ++++++++++++++ scripts/update_copyright.py | 155 ++++++++++++++++++++++++ tests/release/__init__.py | 0 tests/release/conftest.py | 81 +++++++++++++ tests/release/synthetic.py | 100 +++++++++++++++ tests/release/test_cli_permutations.py | 147 ++++++++++++++++++++++ tests/release/test_cross_backend.py | 91 ++++++++++++++ tests/release/test_golden_regression.py | 81 +++++++++++++ tests/release/test_install_matrix.py | 60 +++++++++ tests/release/test_long_video_stress.py | 85 +++++++++++++ tests/release/test_output_validation.py | 153 +++++++++++++++++++++++ tests/release/test_synthetic_matrix.py | 50 ++++++++ tests/release/test_vfr_accuracy.py | 71 +++++++++++ 16 files changed, 1389 insertions(+) create mode 100644 .github/workflows/release-test.yml create mode 100644 RELEASE-PLAN.md create mode 100644 scripts/generate_goldens.py create mode 100644 scripts/update_copyright.py create mode 100644 tests/release/__init__.py create mode 100644 tests/release/conftest.py create mode 100644 tests/release/synthetic.py create mode 100644 tests/release/test_cli_permutations.py create mode 100644 tests/release/test_cross_backend.py create mode 100644 tests/release/test_golden_regression.py create mode 100644 tests/release/test_install_matrix.py create mode 100644 tests/release/test_long_video_stress.py create mode 100644 tests/release/test_output_validation.py create mode 100644 tests/release/test_synthetic_matrix.py create mode 100644 tests/release/test_vfr_accuracy.py diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml new file mode 100644 index 00000000..49018800 --- /dev/null +++ b/.github/workflows/release-test.yml @@ -0,0 +1,144 @@ +name: Release Test Suite + +on: + workflow_dispatch: + push: + tags: + - 'v*-release' + +jobs: + static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine pip-audit + - name: Version consistency check + run: | + VERSION=$(python -c "import scenedetect; print(scenedetect.__version__)") + echo "scenedetect.__version__ = $VERSION" + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + TAG_VERSION=${GITHUB_REF#refs/tags/v} + TAG_VERSION=${TAG_VERSION%-release} + if [[ "$VERSION" != "$TAG_VERSION" ]]; then + echo "Version mismatch: scenedetect=$VERSION, tag=$TAG_VERSION" + exit 1 + fi + if ! grep -q "^## PySceneDetect $TAG_VERSION" website/pages/changelog.md; then + echo "Changelog is missing a '## PySceneDetect $TAG_VERSION' heading" + exit 1 + fi + fi + - name: Build and Check + run: | + python -m build + twine check dist/* + - name: pip-audit + run: pip-audit + + release-tests: + needs: static + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.10', '3.13'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Checkout resources branch + run: | + git fetch --depth=1 origin refs/heads/resources:refs/remotes/origin/resources + git checkout refs/remotes/origin/resources -- tests/resources/ + git reset + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install ffmpeg + uses: ./.github/actions/setup-ffmpeg + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[opencv,pyav,moviepy] + pip install opentimelineio pillow psutil pytest + - name: Run release tests + run: pytest -m release -vv --ignore=tests/release/test_long_video_stress.py --ignore=tests/release/test_install_matrix.py + + long-stress: + needs: static + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Checkout resources branch + run: | + git fetch --depth=1 origin refs/heads/resources:refs/remotes/origin/resources + git checkout refs/remotes/origin/resources -- tests/resources/ + git reset + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install ffmpeg + uses: ./.github/actions/setup-ffmpeg + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[opencv,pyav] + pip install psutil pytest + - name: Run long stress test + run: pytest -m release -k long_video -vv + + install-matrix: + needs: static + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Checkout resources branch + run: | + git fetch --depth=1 origin refs/heads/resources:refs/remotes/origin/resources + git checkout refs/remotes/origin/resources -- tests/resources/ + git reset + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build wheel + run: | + pip install build + python -m build --wheel + - name: Test Bare Install + shell: bash + run: | + WHEEL=$(ls dist/*.whl) + PY=${{ matrix.os == 'windows-latest' && 'venv_bare/Scripts/python' || 'venv_bare/bin/python' }} + PYTEST=${{ matrix.os == 'windows-latest' && 'venv_bare/Scripts/pytest' || 'venv_bare/bin/pytest' }} + python -m venv venv_bare + $PY -m pip install pytest "$WHEEL" + $PYTEST -m release -k test_install_bare + - name: Test OpenCV Install + shell: bash + run: | + WHEEL=$(ls dist/*.whl) + PY=${{ matrix.os == 'windows-latest' && 'venv_opencv/Scripts/python' || 'venv_opencv/bin/python' }} + PYTEST=${{ matrix.os == 'windows-latest' && 'venv_opencv/Scripts/pytest' || 'venv_opencv/bin/pytest' }} + python -m venv venv_opencv + $PY -m pip install pytest "${WHEEL}[opencv]" + $PYTEST -m release -k test_opencv_only + - name: Test PyAV Install + shell: bash + run: | + WHEEL=$(ls dist/*.whl) + PY=${{ matrix.os == 'windows-latest' && 'venv_pyav/Scripts/python' || 'venv_pyav/bin/python' }} + PYTEST=${{ matrix.os == 'windows-latest' && 'venv_pyav/Scripts/pytest' || 'venv_pyav/bin/pytest' }} + python -m venv venv_pyav + $PY -m pip install pytest "${WHEEL}[pyav]" + $PYTEST -m release -k test_pyav_only diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md new file mode 100644 index 00000000..30ae6ea4 --- /dev/null +++ b/RELEASE-PLAN.md @@ -0,0 +1,75 @@ +# PySceneDetect Release Checklist + +Use one copy per release (e.g. tick the boxes in a tracking issue or draft PR). +Version referenced below as `X.Y[.Z]` — replace with the real version throughout. + +## 0. Branch setup + +- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. +- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). + +## 1. Code & version + +- [ ] Bump `__version__` in `scenedetect/__init__.py`. +- [ ] Bump `ProductVersion` in `dist/installer/PySceneDetect.aip` (must match `__version__` — `dist/pre_release.py --release` asserts this). +- [ ] No `-dev` / pre-release suffix on the version string for a final release. + +> **Note:** `setup.cfg` reads the package version dynamically via `version = attr: scenedetect.__version__`, and `pyproject.toml` does not declare a `version` field. The single source of truth is `scenedetect/__init__.py`; the `.aip` is the only other place to keep in sync. + +## 2. Docs + +- [ ] Docstrings / API docs reflect any signature changes (`cd docs/ && make html` builds clean). +- [ ] `docs/api/migration_guide.rst` updated if any public API changed. +- [ ] Docstring examples still run (nothing references removed symbols). + +## 3. Website & changelog + +- [ ] `website/pages/changelog.md`: rename the bottom **Development** section to `X.Y (YYYY-MM-DD)` and add a fresh empty **Development** section below it for post-release work. +- [ ] Changelog entry covers: new features, breaking changes, bug fixes, known issues. +- [ ] `website/pages/download.md` updated with the new version / installer link. +- [ ] Any other version-stamped pages updated (`supporting.md`, `cli.md` if commands changed). + +## 4. Tests + +- [ ] Unit tests green locally and in CI: `pytest -vv` (should collect `-m 'not release'` by default). +- [ ] `ruff check scenedetect/ tests/` and `ruff format --check scenedetect/ tests/` pass. +- [ ] Release test suite green: tag a disposable `vX.Y.Z-release-rc` or use `workflow_dispatch` on `.github/workflows/release-test.yml` — all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the 3-OS × 2-Python matrix. See `RELEASE-TEST-PLAN.md` for what the suite covers. +- [ ] `resources` branch has the artifacts the release tests need (goldens under `tests/resources/goldens/`, `tests/resources/stress_15min.mp4`). Re-push if any golden was regenerated. +- [ ] Manual smoke: fresh venv, `pip install .` then `pip install .[opencv]` then `pip install .[pyav]`; run `scenedetect -i

h3Y0Mu<51_*#2J( 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 QjyrNQxrD>(|M!;s8(P!R%K!iX From e115d91d5afd592a69c9c6f36a7e3d51c0c99980 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 22 Feb 2026 21:47:11 -0500 Subject: [PATCH 037/163] [dist] Add new verison of full logo --- dist/logo/pyscenedetect-logo.svg | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 dist/logo/pyscenedetect-logo.svg diff --git a/dist/logo/pyscenedetect-logo.svg b/dist/logo/pyscenedetect-logo.svg new file mode 100644 index 00000000..f4dea3c1 --- /dev/null +++ b/dist/logo/pyscenedetect-logo.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + PySceneDetect + From d4a55ca632c9de42641268aac8d726a2ac01d96b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 22 Feb 2026 22:00:47 -0500 Subject: [PATCH 038/163] [docs] Update assets for site and documentation --- dist/{generate_ico.py => generate_assets.py} | 41 ++- dist/logo/pyscenedetect-logo-bg.svg | 246 ++++++++++++++++++ dist/logo/pyscenedetect-logo.svg | 229 +++++++++++----- dist/pyscenedetect-logo.svg | 65 ----- docs/_static/pyscenedetect_logo.png | Bin 3337 -> 30832 bytes docs/_static/pyscenedetect_logo_small.png | Bin 4344 -> 12190 bytes icons/icon_128.png | Bin 0 -> 4554 bytes icons/icon_16.png | Bin 0 -> 148 bytes icons/icon_24.png | Bin 0 -> 581 bytes icons/icon_256.png | Bin 0 -> 8796 bytes icons/icon_32.png | Bin 0 -> 1603 bytes icons/icon_48.png | Bin 0 -> 2029 bytes icons/icon_64.png | Bin 0 -> 2632 bytes website/pages/img/pyscenedetect_logo.png | Bin 4857 -> 25121 bytes .../pages/img/pyscenedetect_logo_small.png | Bin 3128 -> 15496 bytes 15 files changed, 449 insertions(+), 132 deletions(-) rename dist/{generate_ico.py => generate_assets.py} (70%) create mode 100644 dist/logo/pyscenedetect-logo-bg.svg delete mode 100644 dist/pyscenedetect-logo.svg create mode 100644 icons/icon_128.png create mode 100644 icons/icon_16.png create mode 100644 icons/icon_24.png create mode 100644 icons/icon_256.png create mode 100644 icons/icon_32.png create mode 100644 icons/icon_48.png create mode 100644 icons/icon_64.png diff --git a/dist/generate_ico.py b/dist/generate_assets.py similarity index 70% rename from dist/generate_ico.py rename to dist/generate_assets.py index 9dd535c1..699b1b8b 100644 --- a/dist/generate_ico.py +++ b/dist/generate_assets.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""Generate pyscenedetect.ico from pyscenedetect.svg. +"""Generate pyscenedetect.ico and logo PNGs from SVG sources. Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). """ @@ -10,9 +10,17 @@ import sys import tempfile from pathlib import Path +from typing import NamedTuple from PIL import Image, ImageFilter + +class LogoOutput(NamedTuple): + path: Path + width: int + height: int + source: Path + # Colors matching the SVG design BG = (224, 232, 240, 255) # #e0e8f0 FG = (42, 53, 69, 255) # #2a3545 @@ -31,9 +39,22 @@ SHARPEN_RADIUS = 0.5 DIST_DIR = Path(__file__).resolve().parent +REPO_DIR = DIST_DIR.parent LOGO_DIR = DIST_DIR / "logo" ICO_PATH = DIST_DIR / "pyscenedetect.ico" +LOGO_SVG = LOGO_DIR / "pyscenedetect-logo.svg" +LOGO_BG_SVG = LOGO_DIR / "pyscenedetect-logo-bg.svg" + +# Heights match the natural SVG aspect ratio (1024x480). +# _small outputs use the -bg variant (background included). +LOGO_OUTPUTS: list[LogoOutput] = [ + LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo.png", 900, 422, LOGO_SVG), + LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo_small.png", 300, 141, LOGO_BG_SVG), + LogoOutput(REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo.png", 640, 300, LOGO_BG_SVG), + LogoOutput(REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo_small.png", 462, 217, LOGO_SVG), +] + SVG_FOR_SIZE: dict[int, Path] = { 24: LOGO_DIR / "pyscenedetect-24.svg", 32: LOGO_DIR / "pyscenedetect-32.svg", @@ -88,15 +109,24 @@ def find_inkscape() -> str: sys.exit(1) -def render_svg(inkscape: str, svg: Path, output: Path, size: int): - """Render an SVG to a PNG at the given size using Inkscape.""" +def render_svg(inkscape: str, svg: Path, output: Path, width: int, height: int): + """Render an SVG to a PNG at the given dimensions using Inkscape.""" subprocess.run( - [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(size), "-h", str(size)], + [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(width), "-h", str(height)], check=True, capture_output=True, ) +def render_logos(inkscape: str): + """Render the logo SVG to all required PNG outputs.""" + print("Rendering logo PNGs...") + for entry in LOGO_OUTPUTS: + print(f" {entry.path.relative_to(REPO_DIR)} ({entry.width}x{entry.height}) [source: {entry.source.name}]...") + render_svg(inkscape, entry.source, entry.path, entry.width, entry.height) + print(f" Done ({len(LOGO_OUTPUTS)} files).") + + def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: """Render the SVG at all icon sizes, applying sharpening where configured.""" images = [] @@ -109,7 +139,7 @@ def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: else: svg_path = SVG_FOR_SIZE[size] print(f" Rendering {size}x{size} using {svg_path.name}...") - render_svg(inkscape, svg_path, png_path, size) + render_svg(inkscape, svg_path, png_path, size, size) img = Image.open(png_path).copy() if size in SHARPEN_AMOUNT: img = img.filter(ImageFilter.UnsharpMask(radius=SHARPEN_RADIUS, percent=SHARPEN_AMOUNT[size], threshold=0)) @@ -135,6 +165,7 @@ def main(): images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) print(f"Output ICO: {ICO_PATH}") + render_logos(inkscape) if __name__ == "__main__": diff --git a/dist/logo/pyscenedetect-logo-bg.svg b/dist/logo/pyscenedetect-logo-bg.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/dist/logo/pyscenedetect-logo-bg.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/dist/logo/pyscenedetect-logo.svg b/dist/logo/pyscenedetect-logo.svg index f4dea3c1..22cbc48c 100644 --- a/dist/logo/pyscenedetect-logo.svg +++ b/dist/logo/pyscenedetect-logo.svg @@ -15,14 +15,75 @@ included LICENSE file, or visit one of the above pages for details. viewBox="127.91564 143.58975 147.2796 69.037314" version="1.1" id="svg2" - sodipodi:docname="pyscenedetect-logo.svg" + sodipodi:docname="pyscenedetect-logo-nobg.svg" inkscape:version="1.4.3 (0d15f75, 2025-12-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> + id="defs2"> + + + + + + + + + + + + + - - - - - - + + PySceneDetect + y="202.9969" + id="text1" + inkscape:label="pyscenedetect-text">PySceneDetect + + diff --git a/dist/pyscenedetect-logo.svg b/dist/pyscenedetect-logo.svg deleted file mode 100644 index 7f09ad70..00000000 --- a/dist/pyscenedetect-logo.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - PySceneDetect - diff --git a/docs/_static/pyscenedetect_logo.png b/docs/_static/pyscenedetect_logo.png index 63471a6aeb0a3047a741f206af589585960e5da7..26cb1b24e38dadbc1e13e7b3e07e666c1a3ee67a 100644 GIT binary patch literal 30832 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 literal 0 HcmV?d00001 diff --git a/icons/icon_16.png b/icons/icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..8a847435d017c0e924028b538d4763ad6a700610 GIT binary patch literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(Vi}jAr*6yYd$My=%R2|cAF?uH4{lCvBe1VuVRc$tG3g_&C$SZ6lN?E1lQ v%|S)nfVY-8VuIz9CG1_X41S$cW-u_Mq#3W3;(xXfXbpp>tDnm{r-UW|hQ}^? literal 0 HcmV?d00001 diff --git a/icons/icon_24.png b/icons/icon_24.png new file mode 100644 index 0000000000000000000000000000000000000000..43b9f60de72c328162f5232f545683e2dd5c7107 GIT binary patch literal 581 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 LNkvXXu0mjfg+TOk literal 0 HcmV?d00001 diff --git a/icons/icon_64.png b/icons/icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..d9fb3d7b964712fbf43383936f99798580dcacc2 GIT binary patch literal 2632 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 diff --git a/website/pages/img/pyscenedetect_logo_small.png b/website/pages/img/pyscenedetect_logo_small.png index 5214519621782c522a66d8d052f9529a9def5643..0634b34c4893b6daaf8a4a138d0e9c3de975d4a5 100644 GIT binary patch literal 15496 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 literal 3128 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 VwoSsT! Date: Thu, 26 Feb 2026 21:33:57 -0500 Subject: [PATCH 039/163] [docs] Improve spacing of elements --- icons/icon_128.png | Bin 4554 -> 0 bytes icons/icon_16.png | Bin 148 -> 0 bytes icons/icon_24.png | Bin 581 -> 0 bytes icons/icon_256.png | Bin 8796 -> 0 bytes icons/icon_32.png | Bin 1603 -> 0 bytes icons/icon_48.png | Bin 2029 -> 0 bytes icons/icon_64.png | Bin 2632 -> 0 bytes website/overrides/main.html | 6 +----- website/pages/index.md | 3 +-- website/pages/style.css | 5 +++++ 10 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 icons/icon_128.png delete mode 100644 icons/icon_16.png delete mode 100644 icons/icon_24.png delete mode 100644 icons/icon_256.png delete mode 100644 icons/icon_32.png delete mode 100644 icons/icon_48.png delete mode 100644 icons/icon_64.png diff --git a/icons/icon_128.png b/icons/icon_128.png deleted file mode 100644 index 219b080b0fac1013ee63162969fbc85a533219db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4554 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 diff --git a/icons/icon_16.png b/icons/icon_16.png deleted file mode 100644 index 8a847435d017c0e924028b538d4763ad6a700610..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(Vi}jAr*6yYd$My=%R2|cAF?uH4{lCvBe1VuVRc$tG3g_&C$SZ6lN?E1lQ v%|S)nfVY-8VuIz9CG1_X41S$cW-u_Mq#3W3;(xXfXbpp>tDnm{r-UW|hQ}^? diff --git a/icons/icon_24.png b/icons/icon_24.png deleted file mode 100644 index 43b9f60de72c328162f5232f545683e2dd5c7107..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 581 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 LNkvXXu0mjfg+TOk diff --git a/icons/icon_64.png b/icons/icon_64.png deleted file mode 100644 index d9fb3d7b964712fbf43383936f99798580dcacc2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2632 zcmV-O3b*x%P)1 zeQXow9e&=s_wH<;^96Jpg?8g(u)zkUKtk*|Njt5BY}49povKYM>jrIsPTE#Y)wI9X zb`zD*)@|L&R0+{(ld4sv(%L~F3HdMyUn$=ZC^SRrBw95jaXuXT?tC}>UdK-2#EyZG zIOLH?a=yoR_x!%z-}}A?a~uaBTmcks*A_78M-DI>fQ@Mi#+dj`y2gOSfKJD;cUgT! zI~Oem*#AEK5SHR;2jm5qrh+9&f~qP|xB{+72uC6?qZVSZScY%L+7SsY;+B&4D*)3< zyd*Jju0T;ZG)+NQR|xy68`02k2z5UfKEdU3q2=&VELpM$ne+p=mkf|w zUgqRwP2}>$OIHvKHlwDt5vNX_hN7w>&ne%F3JXVw9u7*4mcz%edDBJ!0~znjkxO3g z=|`~nFzV`>P*Zad;ei2YnkEDl70nX4pA9(YIDY&DDErB%wr%Hz0oa&c{s6(?p}3b% zpN68SP!$yldD*3<^>QZAJ-~q0))V;B@?{z80KY{|*?XCDxLgW)LPK8OzrPLq9`H_R^7KEqXF2}kGj*16BFv?+S+>5*3{#?sMt!}%L*6VoAL5^AkhmJ zd=_uM@wyllZfgWcqrBWZ%F8ZI73XKCRP5UYih#3c&LI+wLY5@N($oPy5#5)Z!OK+K z+#c7Yh)#V*Sy6DL5*EvO=O&a9Kj@iBo83Y(ZT^BQC#xMGR4zriw0%nvbax z9T#lDG%ZA z)CqPRSf&Xp7K3S;kQ|0J#rMPSD@Iwsk7Y|2!!m7{mI=c!Gfs+l;t%qoM|b1W`&Up~ z+aPi|-eW6JH4Q2mrJw*)p*a!OB!)zh=;ce73b7=*xa5AoX5w=)N>^8=NzfR>psK1k zbM_pzYAIeHg;Ph4tHSzav7Ws3?7pJ*jz|mxqdg4?bTp0s%kno_iOJXxt!Vn68Xd-R!{=e^5b_ zWqH!X)5DuLkjI^!6C*f%5o8i3Gmoi=Et{Y`zI6jQ6R$s+$FadYjz%$W-rZQg-Uq+0 z1b*K-^Hd+my!>5&an%-QB(7 zJHtg!Rr3^wgH-!-`oX~9hc|j3l3n}Ku=HKh|Z8vBUDh3k0*Yz4d>2Z5Ms$=mSy6x$G(TQ3vGDs#A&$Qu2B(S z8YJ6>O~R9w&8-kmz_A@T3~0Z20^OmZRODjXC^NMG_p0av2>tzGyzu<5@!fA%L6d;M zS06=ZXQw!y_~(K)C^|OJ2liv>K;$> z##wO;1O0s|994s^4{>P7?3(~iIxwmM6w>tN$_F+;bH!WYv5FvpCQHR@SL2OcFC$E~ zgaIB2*x%HOr?>tLZnqneNCazEufo4}|0yG9)vA?v>+i25Ic^E4Yd(q1PyIB>S-Elr z_Wa{77#QTZB%r?KG&ViCZHTjCIrjW>=Lk;2ku&)5rme!@i3sVq-ifQ1-$Tdsp&Gyv zi=uR~ZZ$zl~^$l|JJ$P!{FJU_|$nsF+rh_#6z%bJH;uQK7 zm1U@^dN3*8>(%k*TmQk4qpe~|j67CR?#DO2{*|P7;_TT|jpoD0lAQ9uIz0T%hmzt+ zz`x%)fV%o%l0!>b4}a@xN%2WeU1O57X7zn|u^Q5_G;?elyH zWxFiKhaZfwb0jQUke`RSi`P!O>2Nj?%d@#8S(Dx#o3RTbgz1p~Hv {% endif %} -{% if page.is_index %} - 🎥 {{ config.site_name }} -{% else %} - -{% endif %} + {% endblock %} diff --git a/website/pages/index.md b/website/pages/index.md index 5dadd243..916b2281 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -1,11 +1,10 @@ + PySceneDetect

-PySceneDetect is available via `pip` as either [`scenedetect`](https://pypi.org/project/scenedetect/) (depends on `opencv-python`) or [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/) (depends on `opencv-python-headless`). Both ship the same `scenedetect` Python module — install whichever OpenCV variant suits your environment. +PySceneDetect is available via `pip` as either [`scenedetect`](https://pypi.org/project/scenedetect/) (depends on `opencv-python`) or [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/) (depends on `opencv-python-headless`). Both ship the same `scenedetect` Python module -- install whichever OpenCV variant suits your environment. ## Windows Build (64-bit Only)   diff --git a/website/pages/faq.md b/website/pages/faq.md index 18b1f72d..02e4e855 100644 --- a/website/pages/faq.md +++ b/website/pages/faq.md @@ -16,7 +16,7 @@ For server environments without GUI libraries, install the headless variant inst pip install scenedetect-headless ``` -Both packages ship the same `scenedetect` Python module — you only need one. +Both packages ship the same `scenedetect` Python module -- you only need one. #### How can I enable video splitting support? From c8b3a3dff2783eb61897f439089067cc065137f2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 28 Apr 2026 23:30:18 -0400 Subject: [PATCH 090/163] [build] Cache installer & ffmpeg on signed builder --- RELEASE-PLAN.md | 10 ++--- appveyor.yml | 19 ++++++---- scripts/generate_assets.py | 17 ++++++--- scripts/generate_goldens.py | 5 +-- scripts/generate_manifest.py | 2 +- scripts/pre_release.py | 18 +++++---- scripts/stage_windows_dist.py | 38 ++++++++----------- ...{bump_installer.py => update_installer.py} | 19 +++------- 8 files changed, 63 insertions(+), 65 deletions(-) rename scripts/{bump_installer.py => update_installer.py} (85%) diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index bdf31c68..618bc868 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -11,7 +11,7 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout ## 1. Code & version - [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Bump the installer project: `python scripts/bump_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. +- [ ] Bump the installer project: `python scripts/update_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. - [ ] No `-dev` / pre-release suffix on the version string for a final release. > **Note:** `pyproject.toml` does not declare a `version` field - the single source of truth is `scenedetect/__init__.py`; the Windows installer `.aip` is the only other place to keep in sync. @@ -42,19 +42,19 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout - [ ] `python scripts/pre_release.py --release` passes (enforces `.aip` <-> `__version__` parity, writes `packaging/windows/.version_info`). - [ ] `pyinstaller packaging/windows/scenedetect.spec` produces a working `scenedetect.exe` - run it against a sample video. -- [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`). -- [ ] `python scripts/bump_installer.py --sync-files` and commit the .aip diff (refreshes the APPDIR baseline so CI's per-build `--sync-only` diff stays small). +- [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir ` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`). +- [ ] `python scripts/update_installer.py --sync-files` and commit the .aip diff (refreshes the APPDIR baseline so CI's per-build `--sync-only` diff stays small). - [ ] Build the MSI via Advanced Installer (`packaging/windows/installer/PySceneDetect.aip`); install into a clean Windows VM and run the CLI. - [ ] After both `pyinstaller` and the MSI build are done (and the portable `.zip` is staged at `dist/PySceneDetect-X.Y.Z-portable.zip`), run `python scripts/generate_manifest.py` to produce `dist/PySceneDetect-X.Y.Z.manifest.json` (per-file SHA256 audit of every artifact) and `dist/SHA256SUMS` (flat `sha256sum -c` compatible). Both are attached to the GitHub release in step 7. -> **GUI required for structural changes.** `scripts/bump_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: +> **GUI required for structural changes.** `scripts/update_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: > > - Moving the .aip or its source tree (the build's `SourcePath` references are stored relative to the .aip and aren't rewritten by `/NewSync` - cf. the `dist/installer/` -> `packaging/windows/installer/` move that broke the relative paths until they were edited in the GUI). > - Adding/removing build configurations, features, or prerequisites. > - Editing dialog layouts, branding bitmaps, install sequences, custom actions, file associations, or shortcuts. > - Changing `UpgradeCode`, install directory layout (`APPDIR` location), or per-component attributes. > -> When in doubt, open the .aip in AdvancedInstaller, make the change, save, and commit the resulting diff. Re-run `bump_installer.py` afterwards if the version-identity fields need refreshing. +> When in doubt, open the .aip in AdvancedInstaller, make the change, save, and commit the resulting diff. Re-run `update_installer.py` afterwards if the version-identity fields need refreshing. ## 6. Cut the release diff --git a/appveyor.yml b/appveyor.yml index b04b84d7..0bebe2e3 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,6 +2,11 @@ build: false +cache: + - 'ffmpeg-%ffmpeg_version%-full_build.7z -> appveyor.yml' + - 'packaging\windows\installer\advinst.msi -> appveyor.yml' + - '%LOCALAPPDATA%\uv\cache -> pyproject.toml' + # Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release branches: only: @@ -37,10 +42,10 @@ install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - - python -m pip install --upgrade pip build wheel virtualenv setuptools - - python -m pip install .[docs] - - python -m pip install --upgrade -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg - - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z + - python -m pip install uv + - uv pip install --system .[docs] + - uv pip install --system -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg + - if not exist ffmpeg-%ffmpeg_version%-full_build.7z appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r # moviepy.config reads FFMPEG_BINARY (which routes through imageio_ffmpeg) at import time. # `--no-binary imageio-ffmpeg` strips the bundled ffmpeg, so point it at the GyanD copy @@ -57,7 +62,7 @@ install: # portable .zip - keeps CI and local builds in sync (see scripts/stage_windows_dist.py). - python scripts/pre_release.py --release - pyinstaller packaging/windows/scenedetect.spec - - python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg --portable-zip + - python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING MSI INSTALLER * * @@ -66,7 +71,7 @@ install: - cd packaging/windows/installer - ps: iex ((New-Object Net.WebClient).DownloadString('https://raw.githubusercontent.com/appveyor/secure-file/master/install.ps1')) - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi + - if not exist advinst.msi appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn # Resolve the installed Advanced Installer bin path dynamically - the upstream # MSI is unversioned so the directory name (Advanced Installer X.Y.Z) drifts. @@ -82,7 +87,7 @@ install: # release tag and must stay stable across rebuilds for upgrade-chain integrity. # On non-tag builds, also pass --dev so the MSI is named PySceneDetect-{ver}-dev-win64.msi # (keeps dev artifacts distinguishable from signed releases). - - if "%APPVEYOR_REPO_TAG%"=="true" (python scripts/bump_installer.py --sync-only) else (python scripts/bump_installer.py --sync-only --dev) + - if "%APPVEYOR_REPO_TAG%"=="true" (python scripts/update_installer.py --sync-only) else (python scripts/update_installer.py --sync-only --dev) # Snapshot the post-sync .aip and the actual payload tree as build artifacts. # The committed .aip is a baseline; CI adapts it to its own pyinstaller output # and we never write back to git, so these snapshots are the authoritative diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py index 14e64654..083a0a88 100644 --- a/scripts/generate_assets.py +++ b/scripts/generate_assets.py @@ -1,8 +1,15 @@ -#!/usr/bin/env python -"""Generate pyscenedetect.ico and logo PNGs from SVG sources. - -Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). -""" +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Generate pyscenedetect.ico and logo PNGs from SVG sources. Requires Inkscape and Pillow.""" import contextlib import shutil diff --git a/scripts/generate_goldens.py b/scripts/generate_goldens.py index 6c0c24d5..26bdf107 100644 --- a/scripts/generate_goldens.py +++ b/scripts/generate_goldens.py @@ -9,10 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Golden Cut-List Generator - -This script generates golden cut-lists in JSON format for the release test suite. -""" +"""Generates golden cut-lists in JSON format for the release test suite.""" import argparse import json diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py index acfd0776..0626de16 100644 --- a/scripts/generate_manifest.py +++ b/scripts/generate_manifest.py @@ -38,7 +38,7 @@ def msi_version(raw: str) -> str: - # Mirror scripts/bump_installer.py - the artifact filename uses the + # Mirror scripts/update_installer.py - the artifact filename uses the # normalized X.Y.Z form, not the Python __version__ string. parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")] while len(parts) < 3: diff --git a/scripts/pre_release.py b/scripts/pre_release.py index 1335fe09..a38a385b 100644 --- a/scripts/pre_release.py +++ b/scripts/pre_release.py @@ -10,11 +10,13 @@ # included LICENSE file, or visit one of the above pages for details. # -# Pre-release script to run before invoking `pyinstaller`: -# -# python scripts/pre_release.py -# pyinstaller packaging/windows/scenedetect.spec -# +""" +Pre-release script to run before invoking `pyinstaller` when building the Windows distribution: +```bash +python scripts/pre_release.py +pyinstaller packaging/windows/scenedetect.spec +``` +""" import sys from pathlib import Path @@ -23,7 +25,7 @@ sys.path.insert(0, str(REPO_DIR)) sys.path.insert(0, str(SCRIPTS_DIR)) -from bump_installer import msi_version # noqa: E402 +from update_installer import msi_version # noqa: E402 import scenedetect # noqa: E402 @@ -40,12 +42,12 @@ installer_aip = INSTALLER_AIP.read_text() # The .aip stores the numeric MSI form (e.g. "0.7.0"), not the Python __version__ # (which may be "0.7-dev0", "0.7", "0.7.1", ...). Normalize through the same - # function bump_installer.py uses to write the .aip so the comparison is apples-to-apples. + # function update_installer.py uses to write the .aip so the comparison is apples-to-apples. expected = msi_version(VERSION) aip_row = f'' assert aip_row in installer_aip, ( f"Installer ProductVersion does not match normalized {VERSION!r} ({expected!r}). " - f"Run `python scripts/bump_installer.py` to refresh the .aip." + f"Run `python scripts/update_installer.py` to refresh the .aip." ) with VERSION_INFO.open("wb") as f: diff --git a/scripts/stage_windows_dist.py b/scripts/stage_windows_dist.py index 469b567a..690497d6 100644 --- a/scripts/stage_windows_dist.py +++ b/scripts/stage_windows_dist.py @@ -9,35 +9,36 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Stage non-pyinstaller assets into dist/scenedetect/. +"""Stages Windows distribution assets into dist/scenedetect/. -Pyinstaller produces only scenedetect.exe + _internal/. This script adds -the rest of what both the AdvancedInstaller MSI and the portable ZIP need: -ffmpeg.exe + its LICENSE, the project LICENSE / README.txt, third-party -licenses, and sphinx-built docs. Mirrors the inline staging steps that -appveyor.yml used to do, so CI and local builds stay in sync. - -Sequence in a release: +Sequence in a release to generate the installer: +```bash python scripts/pre_release.py pyinstaller packaging/windows/scenedetect.spec - python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip - python scripts/bump_installer.py --sync-files + python scripts/stage_windows_dist.py --ffmpeg-dir + python scripts/update_installer.py --sync-files AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip python scripts/generate_manifest.py +``` ---ffmpeg-dir points at a directory containing ffmpeg.exe and its LICENSE -(e.g. the extracted GyanD codexffmpeg release). If omitted, the script -falls back to extracting ffmpeg.exe from packaging/windows/thirdparty.7z; -LICENSE-FFMPEG is then a stub since the bundled archive doesn't carry it. +This script assumes it is run on a Windows machine. """ +# TODO: This should be called from the Github Actions workflow as well, right now it's only +# done from the appveyor one. When that's done it should be merged with update_installer.py +# into a combined "prepare_windows_dist.py". + import argparse import re import shutil import subprocess import sys import zipfile + +if sys.platform != "win32": + print("Error: stage_windows_dist.py must be run on Windows.", file=sys.stderr) + sys.exit(1) from pathlib import Path REPO_DIR = Path(__file__).resolve().parent.parent @@ -174,11 +175,6 @@ def main() -> None: help="Directory containing ffmpeg.exe and its LICENSE. " "If omitted, ffmpeg is extracted from packaging/windows/thirdparty.7z.", ) - parser.add_argument( - "--portable-zip", - action="store_true", - help="Also produce dist/PySceneDetect--portable.zip.", - ) args = parser.parse_args() if not DIST_TREE.exists(): @@ -191,9 +187,7 @@ def main() -> None: copy_file(PACKAGING_WIN / "README.txt", DIST_TREE / "README.txt") stage_thirdparty_licenses() build_docs() - - if args.portable_zip: - make_portable_zip(msi_version(scenedetect.__version__)) + make_portable_zip(msi_version(scenedetect.__version__)) if __name__ == "__main__": diff --git a/scripts/bump_installer.py b/scripts/update_installer.py similarity index 85% rename from scripts/bump_installer.py rename to scripts/update_installer.py index 2f9af18d..5b57ad4a 100644 --- a/scripts/bump_installer.py +++ b/scripts/update_installer.py @@ -9,21 +9,14 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Bump the AdvancedInstaller .aip project for a release. +"""Update the AdvancedInstaller .aip project for a release. Usage: - python scripts/bump_installer.py # version bump only - python scripts/bump_installer.py --sync-files # bump + re-sync APPDIR - python scripts/bump_installer.py --sync-only # re-sync APPDIR only (CI) - python scripts/bump_installer.py --sync-only --dev # CI dev build (renames MSI) - python scripts/bump_installer.py --version 0.7.0 # explicit version override - -The committed .aip is a baseline; CI's --sync-only adapts it per build and is -never written back to git. Refresh locally with --sync-files before each release. - -All paths shell out to AdvancedInstaller.com to preserve .aip invariants -(line endings, attribute ordering, GUID casing). Override CLI discovery with -the ADVINST environment variable. + python scripts/update_installer.py # version bump only + python scripts/update_installer.py --sync-files # bump + re-sync APPDIR + python scripts/update_installer.py --sync-only # re-sync APPDIR only (CI) + python scripts/update_installer.py --sync-only --dev # CI dev build (renames MSI) + python scripts/update_installer.py --version 0.7.0 # explicit version override """ import argparse From 523a83f720b5cc62aee1258c9f4fe54fc724a495 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 1 May 2026 21:43:43 -0400 Subject: [PATCH 091/163] [dist] Generate installer artifacts dynamically rather than persisting in git --- .gitignore | 1 + RELEASE-PLAN.md | 9 +- appveyor.yml | 7 + docs/_static/favicon.ico | Bin 27826 -> 28910 bytes .../Generated Images/installer_banner.jpg | Bin 1771 -> 0 bytes .../installer_banner.scale-125.jpg | Bin 2348 -> 0 bytes .../installer_banner.scale-150.jpg | Bin 2870 -> 0 bytes .../installer_banner.scale-200.jpg | Bin 4214 -> 0 bytes .../Generated Images/installer_banner.svg | 394 ++++++++++------- .../Generated Images/installer_logo.jpg | Bin 5347 -> 0 bytes .../installer_logo.scale-125.jpg | Bin 7371 -> 0 bytes .../installer_logo.scale-150.jpg | Bin 9631 -> 0 bytes .../installer_logo.scale-200.jpg | Bin 15187 -> 0 bytes .../Generated Images/installer_logo.svg | 379 +++++++++++------ packaging/windows/installer/PySceneDetect.aip | 2 +- .../windows/installer/installer_banner.png | Bin 8260 -> 8303 bytes .../windows/installer/installer_banner.svg | 395 +++++++++++------- .../windows/installer/installer_logo.png | Bin 7373 -> 6809 bytes .../windows/installer/installer_logo.svg | 380 ++++++++++------- .../windows/installer/psd_square_small.ico | Bin 173989 -> 28910 bytes packaging/windows/pyscenedetect.ico | Bin 27826 -> 28910 bytes pyproject.toml | 4 +- scripts/generate_assets.py | 214 +++++++++- scripts/pre_release.py | 11 +- website/pages/img/favicon.ico | Bin 27826 -> 28910 bytes 25 files changed, 1192 insertions(+), 604 deletions(-) delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.scale-125.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.scale-150.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.scale-200.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.scale-125.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.scale-150.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.scale-200.jpg diff --git a/.gitignore b/.gitignore index 1d488715..ec426043 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ docs/_build/ website/build/ +scripts/local/ tests/resources/* *.mp4 *.jpg diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index 618bc868..2e968bc8 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -5,16 +5,13 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout ## 0. Branch setup -- [X] Create / fast-forward release branch: `releases/X.Y` off `main`. -- [X] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). +- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. +- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). ## 1. Code & version - [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Bump the installer project: `python scripts/update_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. -- [ ] No `-dev` / pre-release suffix on the version string for a final release. - -> **Note:** `pyproject.toml` does not declare a `version` field - the single source of truth is `scenedetect/__init__.py`; the Windows installer `.aip` is the only other place to keep in sync. +- [ ] Regular release: No `-dev` suffix or other, pre-release: has `-dev0`, `-dev1`, ... ## 2. Docs diff --git a/appveyor.yml b/appveyor.yml index 0bebe2e3..689716e4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,6 +6,7 @@ cache: - 'ffmpeg-%ffmpeg_version%-full_build.7z -> appveyor.yml' - 'packaging\windows\installer\advinst.msi -> appveyor.yml' - '%LOCALAPPDATA%\uv\cache -> pyproject.toml' + - 'C:\Program Files\Inkscape -> appveyor.yml' # Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release branches: @@ -52,6 +53,12 @@ install: # we just extracted; otherwise pre_release.py and pyinstaller analysis crash on # `import scenedetect`. The runtime hook (pyi_rth_scenedetect.py) does the same at exe runtime. - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' + # Inkscape is required by scripts/pre_release.py --release (regenerates installer JPGs + # from the master SVG). Not preinstalled on the AppVeyor VS2019 image; cached + # in `C:\Program Files\Inkscape` (see cache: section) so we only re-install when + # the cache is busted (appveyor.yml changes). + - if not exist "C:\Program Files\Inkscape\bin\inkscape.exe" choco install inkscape -y --no-progress + - 'SET PATH=%PATH%;C:\Program Files\Inkscape\bin' - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING WINDOWS EXE * * diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico index 019c86150820b2975616d560c6cd01bc7cdd0aeb..bf8cbf10a2938375fc07a27300dd44f57df47bba 100644 GIT binary patch literal 28910 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%0DsthoEYoDtMM%n}KQ1z& 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni diff --git a/packaging/windows/installer/Generated Images/installer_banner.jpg b/packaging/windows/installer/Generated Images/installer_banner.jpg deleted file mode 100644 index 2652dd54bcbe775b91d3001fcdfa3a21f9fdf506..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1771 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 LRm>kog1i3%Vs3C> diff --git a/packaging/windows/installer/Generated Images/installer_banner.svg b/packaging/windows/installer/Generated Images/installer_banner.svg index 2a9ab287..a554ac8d 100644 --- a/packaging/windows/installer/Generated Images/installer_banner.svg +++ b/packaging/windows/installer/Generated Images/installer_banner.svg @@ -1,150 +1,246 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + - \ No newline at end of file + diff --git a/packaging/windows/installer/Generated Images/installer_logo.jpg b/packaging/windows/installer/Generated Images/installer_logo.jpg deleted file mode 100644 index 210bc2b2f353d99c1d1596193196bfad12c7e2f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5347 zcmeH}do#PAfx+P-qHqL4 zR8-hIRQMf;q7gDG>n+4qkPahNPhkwg;?u>|wiegR?rvkN8@l<0qa;?Yl3OjWp{a$} z)*)=zXk@&}WZTb{R@U2XZ1*_q-RHR9>43Y(k)xi+ypH<^1fC8GJ`)mgF)}LpPcf9M z35nOPC*8Q2Ov}j3x|5wl|Ml*@lG3vKvyuRxX?l_5m8aNC~}?)CK4nxI9e2;vR+Kaf`mML zYK5vnm^fx@e0p&`O3iRLTh`65O=6`wp-*Fc9__DW{~TEOzeV;t*gv>l14%ecxOi|h zAc8MVM=s0Vc5-o##oVh(MZoD=MR{Y>HUYRp1d?JO zzKhEs_Gz?|M~v=3p!Wbd7RSCiLgXlPklAa9k8AvCxmTQ@v{4s2;}%AA-PC5S?2oe| zH~f+YfmUBT2-KB8Kz?!1bHkR3Va=hdA%N14fPh7{5d=g!c*h`cG06e~Y41}Y5auP= zFnJ5(hg{F`q#z>1SAfW#X1)j@F%}Nm^naLD9)n#}yYh=g_u{l9a?G$N` zS+r8iRvJZDwc%Uv|AAk!Rt5r$+&nTfv1b-mn93Sy`O2|C z2eH&xU7B-SJ&kZl7IP09W@YCy`u?Xb?%JDcJxi)*^lTn;GYev%)To~??JzD7O#>O3w|_sU1ZRrc&d@l!2#dswGz0>tbd zNMiT~XJ6vvfg06gQ<T5PzKQAah<3BIwv@KZp)kPE?bX4 zz~zq}8yWmFO`{|W>FBkkbiESP3Kst=DV{&gy|8byU5mnLi6JL?4hh2XN`hrbWvGLO2~xlPP0uzDcRfls%;O_ z-CO(PcHDo#>h?IYZ&o76Qd^^S^0;oho*Mh*yV94uWX037Csr>zrB*a)Ps_JYA5F$q zz+aprDwS5r6jd>TY&e+Dd+0vVH%JyNOG9&-u=Euknzn&}vclNxECjkO&qyuYxjLRj z0~I3{;S##~D~Fee2Zm(nq#`VENO!it-8YEd zj9GuVEfwB_Bv=fz`3}huHp@Ne;iee(aO;L>Y~uSaGOM1Iprn0p&lZQl&!^W-e>&NJ z6;DtQ&q(6CDcAWHAPLq3kD|Ii(OOT)Am_$&8t!+xClBP%80X?QqX+8NNTs#rA&LhZ zkPNbCq&d07;J+`{c$Ud2ExVN!Ud(THG?qf!q zItS(-fB&5G>{ywV;9#}`2*8FvXs`4I!iv-_&G$=~+0|ZbaM8i(IX0VRV;e%v*D&1m zT`huBFS$Ao0?Y`erir6duU%mf{UDO0H{}x_W&Ac&2RCiL0Rl4vNWK75dGnq2=G5o1 z9S~@M7eb&#jyT575G+?B3xew*K#p8x>HnmnndCy`DVsomHy$B?fCRA*#)QD|CES;3 zQh?(4XMfp_V5Go`L1raVpC&CBeInSsQT}>lzJ7X+xM~?i>%HKZF(bXy?FhbvYrLY9 z?}azB!#o%He(D@T-=3aHyRb^Dvn}DH7d{Qq#3gvlCfnj+exY`n_W29W`9sz01cA9x z50?vpyOare?$|}@1gr)Eg@yb?hnB-n{9AKq6h_g!w3gWZ->x=~__X8lfiDv;JxxNJ z?s(QICaQQou#w!5*-ji5%uU)0Gz-?K9;g_^I0_}az#BL1kZjF^K+kzE2t0i0pYC^$ zfiHB0K%`A3xrP5_h|06WvFU`k83@$ygnJ++;}%laG@K$_vPDDNgrZN_4gu=i*mL4^ z@}}1BHqfH$1g3Tod_hX%A^9@)qtvdhdv-d{v~KJuXBfkau!&jft?fY@M`)Dhv?W&R aQY-&Qyrr)uKj!quoc_?je@_E&sQWk7IFwWX diff --git a/packaging/windows/installer/Generated Images/installer_logo.scale-125.jpg b/packaging/windows/installer/Generated Images/installer_logo.scale-125.jpg deleted file mode 100644 index bb74d9dc55dfdbe0f60aa156d637580495083399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7371 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 YI55Y7IS$NmV2%TG9Qgm?010aS2jkco+W-In diff --git a/packaging/windows/installer/Generated Images/installer_logo.scale-200.jpg b/packaging/windows/installer/Generated Images/installer_logo.scale-200.jpg deleted file mode 100644 index 2a8f1db8c2c8ae1626220c6b99d0e58188da4389..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15187 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 - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + - \ No newline at end of file + diff --git a/packaging/windows/installer/PySceneDetect.aip b/packaging/windows/installer/PySceneDetect.aip index 0abcfc68..f245c562 100644 --- a/packaging/windows/installer/PySceneDetect.aip +++ b/packaging/windows/installer/PySceneDetect.aip @@ -23,7 +23,7 @@ - + diff --git a/packaging/windows/installer/installer_banner.png b/packaging/windows/installer/installer_banner.png index 3c4b542f8d1f5cf345d3a779c94e00bb45d70a7b..192c26e6d7210e4c473b1bec055c6bbb27786c37 100644 GIT binary patch literal 8303 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 literal 8260 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 ozjXvLo4@?e-?^ - + + id="defs2"> + + + + + + + + + + + + + + + + + + + + - + id="g5" + inkscape:label="logo" + mask="url(#mask-powermask-path-effect8)" + inkscape:path-effect="#path-effect8"> + + + + + + + + + PySceneDetect + diff --git a/packaging/windows/installer/installer_logo.png b/packaging/windows/installer/installer_logo.png index 11eef793072deacbfaacd38c9ccb3c18197221b2..e1d32d2b035d8f8763c99a35d44813f485bb88c4 100644 GIT binary patch literal 6809 zcmeHMX;4#Hx4o#X-KZc98kDJ(ZfHcH0f9sWY-MOvKpFuN0Zn8c(jh{a@zYkq+=_~j z02*vSLI^WqjD8SMAVDTWfFwXPVUA%6WAZM(_y4^guU^%w>L0f%b?a8nK6|gV_B#6{ zUvacMD0@s60Dyz`zghnd0K1X^KqmX!z2KX-CpSI;z_FY5*1ukj!q2m#pSTX?O^LW9 zLdsEvD+))gZLCWOvZ~H$#}I^M!`yadlambDj882I+c6R3W@hlp%mSqNu5F=VJ4?CK z+TrpUhT$cp6nMXzb^0zw&jknZw#nm4FDll?Z*Ki8M+7fK>>{P$H_y$U2D}{z+c!40PsEPTj1OY zIT_%$e?I!(NkP9JS7mC?tR_M0MD7U!MvGvVP=Dc4m4b>U+{^ljZTlK_>M2)E2A~~D zGO%v9R4|;b*0{Y?52-t^1-FSr4>|>f>H{C5Q}{LErjt2X=&2Kt-D2U=l^-%GbHo0UK)Rm~xwt~9pmg+|gT7ITItJoE}rMS@hzqQPm`c$|X~(Ay7e2IWAY<;vMcF|q?~`oGEtm%*|J2lS`aTJ`a<2W>qv@&Hy`#Z%5(NCi8yQhwXThgXdEne=a@#KE z$oAe*;%gn1OWCc5FFc%62kS6A!_0nidvYfyNgDCZZL52LJL;%wch77#zJ03e-m&!{ zwXmqj)UP*RQ6qlKey(v=-*CGt(Ah9dO7cc7ezFHiEBhz(6p$Cs9xrl;s01NrsrknLya9q-XhbA7v?G<8!3| zTQ{$p^}hX6n|!*D)mngE2n3k2&e@jVO7Va4*~6cUtkAPu8*ig*jN}+5z9pE_4Rm!= zjUBCia8I!Bj+DI4(b#`PU()o3 zSgx+&?ww$N;287=!F42cDQt^MBxjm&5AIS!;IE+!jH?z{QTo&h1w3;`sTo ziE5a!2(BBo{IBE-fNU~!Om_Fa1=S&*E@Eqq?xcD2mzx5)#LUCGoJ<&6K*Q&bJa=#U z;UmclqM&f$;em{4cgIx2&2+~4#!&Fb_Jd|)-}knBf#W#jS3fFpxA-E|YEH3Dv_~|f z0W5pyhF`e8u%-SKgF3YsNpcSO&xG+m9A+N;$YgfTvCwcwhtM9<>(JHZdH;)-E>1Ra zVLFad35HPf)a7Gh%nWO7x;N|+ij@muu0x*Ocxsgs`^jxS(Q_`K_!$jEOS?mbRdIEJ z%NV|@s_I&8jKkTL-okoQ9)wEHo@`iO!K^ z$f}T*n3a$vVZS=jjhqo(yT9KU=iV&+HM?23YOA{I%5RZasA}_Qxu}+`uDDMl?q-^@ zWlb9s84`e{kz3T!^>L3rr8Bdw5iSEqQsQhDsXh<$H0@E$jH+qSeI~#`0wA@%jCRpCdJnrmeVX1XB zGqWp&(co4E14%m^*A?d-+Jmcq;o#)U>f51?y>sUTC<%r|mthE;_vnWYhf*QbBj})@ z!j~_PGobUaeJ!yIn?UR7&8L(FZ8KWeQ{B;J^``m8OP8rL{mN#Mml@HsAInp5$h^Z5 zxEVsszt^5$aXs2JXH-I>UTmU-Rc!Cj;?cw2p6OVY94h_)= zHsm7Z8&!8+gZFPWl(;R$U zm`~-p2W^Ol0Gl;w;iYDrr19;@$b-rldai!74{&qSeb7$Mpkb|ftwZD!Ixu!vn0UzD z$6WJ>eq61?Qd4$zXJB!>S!;ZMvh(kcH?;;cC9BDlc?ibV#l>Z=R)B?0y+}rVc)PEi zqiM&F3$^p8jfog!_)ylT#&3Ye!Nl(CCe)H3K2eVNFX_CIv((B;!-dVE`>5V{d20j$ zthn*5^R72gEE-9DWpk=9vk=K`B_+K6Ta&^aU$yy~*bUGU*6Cv}Ub@7q5hj|+L0_Cn zh@IEZ)Ad*g^H}>Hd<}uffi@zC)#Ql|4Wm zh_+kC@;ZT8@pkiy#^uu9o$YiWH8#B%xGUM&Pdi%+SCgGAy^exqnX0lIXb3w5OdYU+ zBz^Y%jOw@NV(qi!GA7#Uww*A$nh|DrpID4F5{HW}jD514}5AemTPMA9) zt4Qn+#O_b*M5oByJ@0ZF1r7g{59>4wElth#$ydb9%)lqxONU?kQ02I75VFdRnyI1T zs{(L=7k?<(s6Kmg8(u6iuZ6by&5t~nv?ZosxSmH8a0P)|Lg{KEy!}fkb%+ijfol#9 z>SIkj%T>xsOR=S;DRQ3F;gD-o!~6t3tp1bC-TBMT?Y9az@r0UJuV|tq^^H*3HZ0ba z=+-%p8B>#PIZ3PAR)2~55+#}xbi=^iLVd`=~NFD*(7BTzrvnshvbrWMnJZ4p1UAG&!Qju~(j!EVoN>5MM%g~7J z=|fF^*J&469|poad8fh}_=F(H!XeS*^N+)WN-Cxy0W0wv%M0wuYhLkQgF{1txcET(z~vs%H+ctDOl=V^ z-NoaYC%U@33C(4n4N_~w)c{cEW22bVYbAx_%|GvhwFP*#fC9PJC*2m~(GTU=ntnH= z1$uk?4E)$f+S_~5wpDFfQFZn4SNMWtUdtx}lYu@-`3?p}r_8S9IW=o#_LB5jnY zh6rbmJfpsG@pe-;PFU113<+2g-<`wu?gEAr4$}ia?>H8=n8TfzE%#_LO)y&GNcYZC15wS)jJ6}sOiD|o~qJq4T%!D*~m|Cb_SaUu)etI$>#-2#ysndirB zG~m&;2Q-fP;}b5X@~F+*Xb5v)U0*O(NhZR(Bbr|tmH9}3~u>;N$M_tS-6=buC<@RZ@&Y(-{5@{GSXbGlr zjG|NCAbIHKr0tW>yR+E;H?q@0Z1ERbetTmzy}PJ3?##MKJ3kGE4gy=FRI*9?PFs~Z z85L`_AJhSCn;&SAS)#e6LPAHM%>DtTnnXAG@t2tK0>GSGR;l3_|UvK_KKP9 zg$oxLCi-B%8IBh4owTXaw@IkcGIMlm!qW7o*DxpTuZ59Kf^OY&CA!y3(#QJypIgRX zPc6P87}Cdf=0_O`j_tI5JbLx0UIrJ<-7VU9JZ83P`RT9SJnZ|dT(#h!t_~9>`w+;g zo`#MFmxM#0-42T^+X2{%3T&GJWun6qK2+)o6X0XI-Wvu9TXNM#DJYk%+k85VYMA%_ z_GEt`q&CW_13V4sE;LDB;$wG2kPi;| z$@n9GvvZ}7G4$s39Y5yIFxe}t3&uzA#h$9v+5An!w5Jfn$R#^f^(2i*5DX3uEDC=l zhm`MVDaBXA#cWS#-k40mH^7zWMw=RIPcxbj%aQzhONE|oH{5{4j*!5-=lzhF$h}k3YBhgA_;E_O~uf;1E6hWdF z*+A#LbpeOYJyuLQGYM*)Y}P>6e>1_uu|juzPt?q`KiCcLVFn-BwNok)i!BF%n<~j| zdSGuQi`s>f=fY5><`(fW9XXj7cVVOh|Cdw1$6@Q7H>(5!T)N}X*EzOtRm{SuaYz{; zU>}M?t9W?I%h9%&ij89+G9dS+s4CrG ze*ib_t{y;9+)cCMk8^P^G<_wi3bFiH2vDJU@*yGES@!lmMkt_=Xe3^Y-K=h04=e?^_23uU5s{ zYnek;`V6=#h0!_KAqQ4Z%HwzX+_>~xtC!X}ioo1dijJI>u@R1*)^oe+5nb%~P6zxv z>-}oC?Ho?n0sy$f-GROUaOUnmW8r@$<0_B{0TRThl%NoiBBInJAVf+?2!xIu5J)VDfRyVZ zL6j;@sUnad2q*}l2uKN$A|W6>!1uCy@7+E3tl!zQXZQQ-%RhO`%sex{nP;Avc_Yr9 z#Y$|J+YADMBu*RYn}a|cv^I0XFdrWsh%EXbE>bUf7=bkd1Ta8^3) zfp>8>cg8#UQ{Fir1A*2BRE4V$;hG2~O$}9q1{|e0;^G-E~Cx$4S!n;xYLEY(r8|2H8nb&u1Z%|B~xA0 z5GWK%4SrPZ=us7bLdDzPlZN+G@$`oME z?@4q%t*;9OB&d=|PH00dZ9_Qx6iOeZp^rcq=%G$(=;!S5TdB5>G{a6?6AM&aJ z%BZb5_8%Pm`y(KF)-L}DTj1u8z<2fpqMZtawS45vd05?gE#&H%;PYwZO9q=fQa>Efpyn4` zH8wKY{>HDWqk~;4tPpgM>#i=n_Xab=?ru}U50~uIn0-IUhe$G}TFZ5jc6N4~K%i6a zuprP??Vmv>AU}bWB-eqq0ed}M4+N^&3j$R>F6kTki<<7r&FXEtbCUz6pZ{ggUeux{RCQn^`4&RovJ1V;}f-f ze_?T9de>sfC-}3yVfY|L(AU){$mX}A!ooCUo7Sf^);*Z{^eZ#cR3eGAa;Mw8^_lcY z%{ABvFD27BjILroH{A! zk{|cJ27Wk(x=)j3Zl~-Z&#ft-2J7jqsC)2~AUZy$7CUax(;PAvNGOPW@H4=}3(q3n zM1F`I831pBXn7FiFdpU#dm)%$b2&r_%AvKh^J0Z+c69W?v}4a-1-3G1v}PMp*V~Jv zs`~D!<=ae!5xor{N*&ffApO>C5X%QA7Sciz{JTGExYqq@X>;KbSHf^78(j@HaDPVr zD*0}9N*6tSh%opooA$MFd8Fm_7EZafZpo1WJO2 z%cTVu4%MC29wXS5=RYxY5eUhFyO2NweY-UTWi&2 zTdKYVwsa1rx4t}2XLUBnSi%sjD88ygjBiB=(j%Bcw6_80NZ$ijf$xDaHh%nXBNJE! zqiU?y1*{a&@+d2dUReZddnlS&2Y6YGl^+N@?ZB6kDUH-AMO$EPoR=M40-YaG!lMt! zHYXa7CEGtvkF+5d3*kugz~coktMqDisLmv^Nk zWZ{aOF2!~wM1LT6xPPQ6Owc^{d6hj;kI>}RMR7YTJxm!j`{z`e$2K2qN=|^OYqBzr ztj5>Qg=9j))zYg=C5NT!1~w^E=6ysJaraf*Vy5!6K_^x%Tie=ZvSouuga=E4ECngO zd?~huM-)36qVo$f+OuLrR0XXk?91hK&CuLe@6i?~BWR*G_aI?g1ojD1=?X{PT^Tjz zKii@XFu$Q<>_aKUuGtrj3x$MzM-8_QzJ4bhOM7AU)PSziIJPU28!q5{ryWYZyZuTj zR-7n1{cy{pmcy&lgbKll-Ke0^cjONL^>41?hbHyzYTc&!3@dk6G$|hOl??-eYl{EN zmGJhXk_4>cX}Un#j(bL0{1bI;(WEWL%mNhlxVi7 z%-j7m`^j-TZ68f$SMB9{;B`^=I32GcZo7zFVU$6B>o=*)I{DLgd~Qi63FVfG%Hm;^ z?=^WO_P}d@3F7CzHrm4U@* zW%n3~^>#d%t*khVB^_JXw-a_#`W}n9&e0J;omaRH-)X^?CRa{~s?NE84^Jfwh^nNC z3Dc^BA3Dpk;hKpx4~%^)rUL9QfI|=Tr9x7qeS~Ow$P}|BvzyOT9-eJ45+My|0bK6- zX7|OiO;h5ARUHzsDud;<&D0oQQyUIK8*%E@a+(Tiqv;-Wa^Hn08hhqcF4<6W-L|lS z$peTMD|g9=d*u%F6~~~&+Cpg`zG?D1gv{8SYEt=K)s)J(1Vct$=yBhzT1@$*8|qNR z^~rIe1<&YnoUca^jN|kfmK+a3$UOlfQ?T&;;FRG5@NxsEfyQw_NPPB|Ai5bFqs>CT zg>z!vw5U60%gDUoD~dq+-gW!V;)f+sRfiqKbW?;`RWe_yVvplPvl{KG{W%B6?fJ3@ z`sidtLFiaEjkel98Y8Tw?#m7xu7zAR*eAo^#Q>}Sk`*xiU?I?8pW!(f!CpgU;t--; z)2i3aCFGTzi5+K5b`G=9o>z7ou_6L?;|rZ%r%@5n5-YIvpx~p_4m}|8u_@{nd|b1b z31{w3G{nHQb|&wER{KQlWk#OGfg!WE|_X*JmIX^${HQGc?{IN5;-DDyg_bp%w zK32`WuiFb6@B*>%`VJ;ib3QAM$+Ie9w!)zPGwu(kC~7)fawGFaq%sdoipuCRkPZMg zR%?^4)cp$Ikt-paPn)Q7hMCaMK!^_`V684Ii(5!t*E_XzWzs&-UG$nyhohXgk*Kcq;Qg}P|g!o`PteAy6`fqI$XE+skQHvfo_FOK(i~6ay^IC0HOKkyl zT!sO2_Vw)WZK(PrEE@vONaHP5y|LUKhV!+qj<5WNZGofV(iK%s|F3&*EFBA-6h){n zBM z+h1Gf*!pZ3615kT^z_9P?LI+*n27P4s2QP+h|(S#iYH;jLE+U>Mp4_@Mz#=f-KQiY z$>J)UUh5vIo(EVVAs-7l8tlidqWR5L6@+#tLn4ePY?8UV*wc~382H4FzQ^UXkbrNu zfb{Y9mFc)DBV+V*K5M8?c1$Kv;p7fj%%g_YL`<;ix6Xo85n2rJDZCw_De3vWco$H` zD;9uzO1(yH8To$gUM^TSMjp8Bu9y7@|;dTBEvOG}KuaQ1s zbIT(){N+#tue#hPZ7>S-JD~u`-%`4Ue&I34(kDm;VSNW6J&+fSf@Nj({988iB~ z`oiKHc||dFSx(7YYp!f-BNC~s`<-XXPr-t3hgW!1evYa3Y$LvS;kJ1&L^xLO9ou(2 z6fXHIgGdTU!GN#HnH|~))Ee`FGNn-m8a(Y6?iV*Jy5~%&9`_!yWK?T^D9O@6-%$D) zRMW2*uB`)ST^n?bKbH2nH(s**wc7H$Lq$ruUv<@Mhf||Cvyc*?1Q_u8mOLgFnV!4T0rPx3b|Z+=k)yHoN}ZTJ->iDEq--PnZ7x6D z{@9{wve(P0{v``ebp2PysO`QqMJ)L7MFL&;dN92-7?>aIigge%7_GTZb_i9-q%SJB;NcC3nZ9 zKj9y-fh!#^25;&qqy!$Hz$*Q8ReM|(jYhYw)qp^OIA!EeV`s8o7Aj7(8<_kEBgdbL zHj2WI zoJ*a(lhiKowQ~?)+6`7T1(*e~Gka;8fG*fEi zs^iWuq^rfN#h*YE2X;4<+6Tjubo^P1Pw#iow!6r)7sBlpg!CEzdQ>03Yi1NlS-y`c zNnL~thvyXgjyE=+p777{YoCtsSD!A2&O>)Bxy|ecj?4{R>y&9SRemp&uu(n$kF0G~&3p7SW$6fPrEBoa~ zDL9Gf=mmtoa7o)}>2>Im0Af$EgLtbuPZX4SA|(4Ko=4!0C}%C@@TXwQclanR$=!)7 zLx)q|&E5HW`0XTTRa8!!nv4)VD}TkE&)Cr-+EvVph6C!VF}|$>Ey}J66cCmUkEDti zph)p3uSm|Y0jQQ+@ix~_?u_Wj+tERXc}t(sV(}M13BwPh`zproz!wg!@D^4mb~pR; zO|#!&`)j1NfQm#t4r>azAW@PMx*A`w0OT75N{)E7Y4Ep?1@pbmZSW{=6#ST-WQ}+) zbSq|B%fSTbRGMKyfGT2ZaVrD!$jRxBqxa6*cXM^N<~WomHzT7CKCKy?J}#^^@Gp<>jCE&rJ?8S>1}SQqeyZuazBJVIX2UIn(3FTd$#;h=(w8iTPsZTyjPf zI5sCjAfa$+Q}tSzR$T~2*iM`FWdY9}^0U&AVR#cJ7R;8bnwwtc6dFnZP2{Ybw6UO| zyJ#-l#)JM|C3G_T*nkXShf%Y1F*I|aLfR^F!agBAJ&*yG!2|uJDJcT(uAqe`8*b9Y zZiNjE1ZS>{rtskJVWGiM;38Y{X=1}kFzhxZrx!n8TCwUi^(C)NA&))m1mxHCIQQNT zpxhsc54E$nn9AjZ+XUr+DLOCt=jK%TvsbLovqE*I@0A>wj$n_pQ0k*T2 zq88{7sJ={XoL-pIOSnDkE&V9_lGz?gkjB(B&kJ@j$Cu`bQWvV>*%4+H zE)7-ml~1pO>^{2l#xEj(> zrUd%$&a3`33_Ec#iTNeTr=U1}wy4PRm$rF$NnugJaP!Y==GkWy6)lcsH)U&3MR6L$ zkK>jX90b)PGI(vuaEztRN@ht3x*;XL%4LoC94_O4T_sfoIH!;gubj!qDn~ZjR2*uw zNB4!b8sEg@GGeaOr?k#AT(H1m)1@{5n}5}!@7$xJidP6R{NR_98L8zkCefz?UPA0meeTlX1z7Uq1%XhXU3wf6xlf+z1K^vF(6a% zpf@L+WY_)QLjR+Q;-5V9=dtR)_WA_I3YbsuEI=S7b?Se4!1`xH{ymNVEamqx^1op3 z+O+v!Ap4IDn*ZzL>ObS~|7ia6&kO&a!~Y4ISNqymx4=O;Zl3q`ejS)!`@neG;H-Z3 IDaY&o0}{P+3IG5A diff --git a/packaging/windows/installer/installer_logo.svg b/packaging/windows/installer/installer_logo.svg index e4107d7c..a554ac8d 100644 --- a/packaging/windows/installer/installer_logo.svg +++ b/packaging/windows/installer/installer_logo.svg @@ -1,154 +1,246 @@ - + + id="defs2"> + + + + + + + + + + + + + + + + + + + + - + id="g5" + inkscape:label="logo" + mask="url(#mask-powermask-path-effect8)" + inkscape:path-effect="#path-effect8"> + + + + + + + + + PySceneDetect + diff --git a/packaging/windows/installer/psd_square_small.ico b/packaging/windows/installer/psd_square_small.ico index f42aecb878ba4253ebda03e91f13358b9d1f0c80..bf8cbf10a2938375fc07a27300dd44f57df47bba 100644 GIT binary patch literal 28910 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%0DsthoEYoDtMM%n}KQ1z& z4TN5*DeaW|Yh*xkjjXT8hW`~EE>kW|+E1iOb+M-wrPER4sszRntt#-;big45BJ-aj zc_sYRSRxAVZ43PQ818A2RRAkdP_RyXL6FWs?2-_gIg6@&#ZESn8R2~U!2s3OTV~sicG%hbKVz= zQv>)ZJ`eS~O`jQJH!58_Cx1PhgxY8*y&5QRiFi4^IaVOr zJt*H^V8vjk>#56lSTd>?X>5DwU3kT}M@xSQ`2gq42{`l!csx$u*-M`ZCX17{UYs5r zTQDBuSTN}Q$>6c+A&Ae<&v#XOxIZ~-GL9pvG=qVH^rwrPv@CQ@*cmw1>3poahh6E1 z8!CgYYX0PsETNoIRwCzgKK);UX`G;J6>pIi6kQRd z0(*%K8&ms)e)xv=EA3)>?C;C1!~XcS4AX}ffHoaTIl|P?QZD%F%J>ay6{TgkdCD+W zQ9+;xQ*vLL4BeYLtjVc$EK7UC;;CCksOCNwV45lIUtfw-wLiwba$(_DFH18Ji!zTR z|HJ@5(qf%;7+4C3 zLhMmUD{8zAQPe?Om$d7i1gUX7YD8&$9$*2K=HG?St5N2V$biEpF@!p17v2b#JP$j+}`qnP1p=tThXbwJu`c4M- zYyGh1vvoucXV`KT9`amj+zXeuIJpY^?fzxk;Vo8#i^tlyRg#Km6{k_J8aam7EuY|idy9svL;Y=k*L zfoai^le+}BnC`b zuafzut3->kh4Q{#_jK20mFAk{_}j*k(vwvW?-P}xFU`sE3yP<6Dgv08LF^-+(Qv2) z!lN&zLmN_xC;=xMfbXajDeevF;uBypHzBLkJ8v;|m_0qn03Mx3{CUlgmXXhs4W7hF z=h}R7!`l0D7Okvze|3!~TH#l^jV8iu>!*RTNwA-7P^c}Jr(ux@CZ=TE!U!_{^AG0S ztnY%%Fs_h#Dt}9I9-uU96Epa>%S}aMoM#|bF#vrXh@pnrDhGb-@)aVh%plUw6@3RaayM*<99zJ<72!phVfoX9c$ zBPd}|K0O!n(}O|#)1JOM{^wPF&xhTxH!tJJc{3ozTD;&3lZw`(uP1m{{qjz$vz_q$ zoGyQ_jpeyuGCr}jo9Lvl2zPhrDqDxa}c%!$=F z@QR;PTT7cX@49SwY`sblcK5UgA)<2x7%6Hx-yZLkW|;lI>vZ%J@jzwV2q@z5M5Hmx z+^)#|zGm1(C}rF@rw>W5cQclP47y%=%filWuC8E(p0kiSwwh|?w)tY0kFg9{e}c-R_vLG^RSo64L%v}wu@+#8SB;`gd(8gc zLuOe;TU6GGy#c3Hw_sywI1uh{FPA;1rVAP>t0#$E=;`ST?`mA(P0o5`I}3#{@X*fO zQz4FU$4pK_f0rzO^-0S33cJ%tOaP^jSgJQ~Si4?bDz-#<${&9;{e<&eE!%8+40q$1 zZ4*lGxBa&bYlNbI%<}BVX}{aXzPuzC8Z2>0)4W=b@YW(a|4Y*GP6Z-&XS{_b?@tw|cqZpdD_S0!h}GsAB2~h* zE1emLk-Qvfvg(`PB9FKQ`D z0f)|ShvkV#gPes%2NU0bAK|jW<5SunlErjy>F@V4eFAtMMVFUX6ve_kM^$Anwrf?) zYV|JQB9k6hIq$T}NLum}Qp-(HIa?f=uJ*Y49aR*2@3F<;cSD-V@BJTA-pXUI{#jY$ zO$V%Acq3}3?Z2el^P&QKAxkCTgR*Ur#jt{*;eN+y~xj2dYr zR*cNX|5C2Z3nVtR)W6&*ysxH;y)f`P!5g3wnvBQr=GJ*!=VTfZWBBCN|w0L z4dDVJk^8%~1qiy4v40@LuXiRcssgA?KVIC1f0i~^==oaFbdU9Q9W4Bv>ZOL}+7;PN z(%Igx`M7apzK*i;Hxr>yLuA}72tvYU2ED4mdd<8r2Fu%F9GjSApSc=&LfN$b8+r~n zcw_B`Ssp9qOUfu|aP|?jd*IfNkSr)^OGD#28{|%eQ(DK@aJ8~wH1IDqcc6%&gZA&* z;;8Pv@h3NP^-q>aD|pf*u)APcq;n91Ja)|xC&lf+}uo zJn#U=)@gQUj7eqhIdtE2|BBn#x1nzQSTEk*Y@1YqtcrTr_upIX8(Zzev^T(+&xnP< zy93BvomU+MYEdjBzq%&Mcgj3)1_h1L;L~V2duB`AdKe-*P3RBj%K+(43yN^ME$2n_ z9fmBxS=l*~)6j}DA12;{@J5MY1!&i^%8;OML2)p;UZ&AGq_C3q8U+|L_+mG?|SfAh{VM| zF8v1KyuG=cG|si$8*O3X;yNW;vst0eg9BFgW9;}CADdSB*QtE3G{Hb|yZL}6{+rTg zD!m$JpT7h6G~+wwWhE7_2O59NMm<82Oz@EsQulXvQ;l|)DFbA6m+}H&8Yhm_Z@JiZ zgT94dY&?(m(Ceb_z}*0))BMFF816)&p*Fq1gL%+IE#WHYc6QX@9*N3zz%TX{wA*J?T9p0C|FI}#6fq&~ zb5yo5KcJoNCs`s162k!8B66SDkDsVM%6Qwpe|Q&m>epJjJv5x(Y&$*Q?z06!9s0_Q zz3D3K?K81>P1`0uMNT4_@KG)Eel7SdtXS;f$ zptE;N8UO59+1M21Bp=faPva#H_a#oND+3hNUF&(UQG7>$3L^^^;N+p!mGdp9_s1QMA$Br)T%XPBCqVY3?W-5K0(RT`dk#y}qcUVUJ?U0i z!(Hg-*qzP%8<*2;l|09X`>bz=&jso8Z1fhO5IXuTf;V^+pI|$sWnWlNU8#TS8{NPFU7lm zG;p0-A|~6fF05`FnBc*=fIp=CBy-+Ewc@opI|hs9G`1TX(~Z1yY>3VL%lUx_+bH3^ zSv5I;URpVS;N=}h2nTc!-})AvQeP1g;GhkT$2omKLwakaD1n2N8$o8!=!ZBe;WGTSMcB4_JYd;2Id_6hb<6@F>5w^9=3B^<|AJGFuG< zH=e(YI9aXt01wO?*ladXSqO04>yMEqJ;+5HUyEfdl4|PyWaZ?v7++oR{(7Tu;A5pb zX{+umOhrsac2o8eg488xDe@vR^iAw*;jGB|{r6AQTl?KTfLy4 zVlj^sigji&vvrGOkORuAC^mzDXdb^O2{RI1;^Zm$L^}wouLXsh5=?G}LW-f4fgRrm zW}X$?zgoSEqTgN;`|4Q#DiWGDrtBN=tqWsDMJy4hyRM(3N@{BA=`BxQlSe%fT9$>D^GBazb5BvL+*^RctQ8DQkPjcU;uP zF{`V05IHmvOi31Ev+czxi>6iUjCrS`o0G^58VhF`R7!FEugm<#evb~0O)$^Zv%f}x z4%~l|GIPysMV+4ZsVW+8d_-ON)JtsB*sVw@kH%uG)-Umep7YBt`k|W`5pO zD8*R8BkY79N3o&(Q>&y=(%fd@CD7Jqcte)^@Zk=_1p!YcwF=rW)W`d-ni;$^#XLAM zp&(;`X-baf;gPBH2z_UO$NZ~pF}qRZHD1^sQnS0rxAMbS)i-KgB9Ns)SsM1PNeR^f0K{KL_xyr`^|z?W53&JYR>XDmYMob zS()Wj$aB?3`|jOi)IjpCGxzmo&(PR}OdRCP;_|ZIx{n)Y1dc>+3b>}03P%{szU1h( zXZk9<3;!SNPOx$}0%cG6rZIDp2E%!5j_li%Fm7?j)a-0;cHjdCD!l2>7I(O5?<0C1 z_e$3gb8dv-#M*J+289ZCN7L?m`Dkp6Qrm9m&c2Y);cTsoRnzu+Xk%gF56a4x=*L^O zzVlmV(sNguvYkXXDB%EPS&Cc6F=u-ib=pd7fKvY5T1@$KBV* zxazvb$j6^fppuhXY~=TCE;oQ$TXf4gw6)d1u>)qSr$?CSg#Mo;?^}2tiT-guz;Dg; zI&Ra|w{d?w)5cz2H0O0?s^Gd_ci?jiEeHC>we^c;R<0Y@U04H;*V)F<#6*n38iLSp zVQIB0Ohv*d%B+?UkNM}lNVezuW&hU!iX^**Z4%5=+&D)^f>0>>$VFrJA6ubn@QLTF zb7&79jf#t$s$WUq>8EwWrl9-~(U1@Pzkr9R4CLZRZDxRqJ)clbYz$Qyb<`$eXt!9e^rB+qGfw0;iYV1a;C%4%mzDl)k>s* zAUgOiC%u<1JJs0xds5PNoq}n7VN+9>#2Q@0)orz|j^XAyl_9Fz9-aBY{yp5=+oi3^ z!PeGv@7$@NkvsJ((e}`U3-_-*_L{bJA@yfc$9AagO$p1dCgCY?9?O8-n94-&ZtOyI z9WdHc+vTF_(R#HHXK4LP*6~hYk#T74akjLRjf2BhjY7cCubzk#*CI~it+K+R~1%MXlhRJag=9-`H4$J#0iDQOgx6!lsp^a zk8UHD-`8v}*6rM)Q@ppp&^GqN<0y|RUiS8061_OEZ-?)-G?o!ql31?#jma?SzT^t@ z|H_$Lu1nTH!uod9Y7aLx-Av~4vbFVMxI^cMQp z*Xh;S;Y6aMFRJhVET{wmAcDo#Fconh^kTygxXfwyBByuAba*0WNV}G8@x?7z08_+3 z!L5I6w@U&UN_lOsb)sV+b@XeRk@->&UA^joL-af+lfQuq-~lYA!;!7VeXFB{+&+hd zLg0bL^{Q1)m&V`hMe~$L_Khb^4ZGVVs{~|}Hz3XsjV(c)CuurqJ4HZ{Pq4xHHrH`r zcl;eAjO&8sH6j_CCDzU8gMtEfVB@0}AX-Yb^b#V6IIdT&+x7JS34U}8wvxhQv7$)! z$qBf8FkTa*R_z(0+(ytaLb9O$3$V!6=-_KMH6X>%JHyqtrJ?rZyh_ye>TRjb zEAiai{yk3tk3t7MzJHcdxf2tA65ljkj1t)>Iaj|RCbqbW=y;F-&eqFc*?ErtU)J2f z6A*SKdB7FCU={Ft{%D{XX~O-vPu!A8i>_7lp;zHA{Bor6Z)9mWD?7_}(J|+*zJl4n z`K!vyE?=5HAAqeqZc0)S4+h zLGqbGJU5=_x2)TZ%>Z-jVjeH7<1|jz+S)#v4LIE6Yxj!}MVfS2fI_@_ViqYQ(8biC z;}n@FcpI9EpvfL1vs)N23?tVt8dlPNo^E~HJL5)JDg{gnzFMK=%AGnS-2|e|@Wu*VW zJ_kL4U0YR|$@eYx8rlAsaG5Zd;-}kHH}oapJ)Okyx4m!|{X~absWeNO{v3C-_12Tn z8>uwius=DTdPpUKKALoDavilGfG%$aVz=vv>=YFqothrSGcq!rmrq@KXEeiv9#K@o z4uUN->E`{L!~v3LmlBi|8S8?SW^5d7wqp2c2);`z$~_(*3G$S<>OGWn{E<2#R1Nz~ zO=0r_D4Ka+LeZ;+w2*vVP~c}(dgWk4f&OgofYtw5M-8kCy#J00AQUt-vjLL`O0ZnWGxnt#bEoPde|3Dc{E)4%(v3b zlU(dUMK*N}@H^MFw;>;Xd}gRV`xw76hA;8ZSuix9Ze%odOyLR8esOk`EVoi>!7Qry z3AqjnIwU*dB0^=O8g^$2VG`GA4|;f5P0^+srTxv`xflKkNEZ?JR57&Pm20t4r~WwH zH~6fo>SBP*PDz>Ry%QX)A2xW)GRsg!mS0j5CF1yu8-^-uqaM}>>$hXAWKoWJEB!w4 zhO~44il?b4DH&+{@@)|=d4>r-CC1gqDKo~u$pJ|J^?nGs0n25;n7a6vsT-1JBy!V3 zKJRFml;|}dW&8IAtY2!?Dinf7Kd3b$-QSdR05^57L%{FLZZF_cOsz04=-fT*waDqd z29O)^$?~*9R>MAC@Rt;Ntt3R9IwE7}@87K%`7igcR8l$S0L1;hjg76TBK=ApW&H)5 znXPI_Ks~$~E#$=~8Y3#NBV*%^bo>qdP(cx>>EGylf;eeNg;bi{Cmm2AZD+?5KYA*O zfiPZY;syUi+oo%DObjHW@cb45M^Fa7S+GGZo=X7}mPi4`2LMFljYiVgES>}wjh-w&D8PmaSkl5LeTa<{GdTt@@}<}OUxIc5nfB2Jl?s4)2$lqm{RLmT zZj0Bi1&xCrp6VhaD!>m#y))i-az;i*davzrUs41ycszBwxs7GWv-kPij?IHzT?hH0 zCK1x1>Oj2V>Pq;&d~gtfW9e{P!I+RMJxd*nD*OCBw7Ls?*Hv# z#pMa)NU!NA$ppLt1bavwDIjXr*4EN9=I(e|gc$eO*>A=GJCeA2ZTQuOGToG%k56Dq z>shz-<4cTfreZUOLdHIuOw59E5XgFJ{G;Si#zU`JHL>g)f)g>)n6OKv+j(ChLpi$_ z2TY_fQC=@X0%QWcO`?FS{2w{~|EIhD-?O;*eNYSMP*&Azq$PH^A_@&Fi6EvUMUk6K8$%;CX z)!3WwaR^SnoaKpawCv>aZ)hsVV~G(VkEitdNy0|Ej)s9U^#5dQ8Y|y?D97c+RHupn10T z*M6P!qYxO7w*(I)n0J!pL2#aoxA+_splSO@Y|057%B4As0@JL_qaXmczwr`}f=n_M z^jafFMFSEw8_oc1hs04T!Ds)B1S&XV3Q@@v+?uQDE%OUD2}&;frx0fOE!g1(DRz`b znpWC$I3GZh$T1U7wynj`crN1b+0!^Bznbg6_15^W>j34=G7tJ`?n3qHfah|Xo3r;* zv1CICD3fJ z`?U580oJGdjwcr*AE;zx4ZK>{S{1UzUT(NNw;i!&0a~d!TVW!OxK6SEXB0#y@Q8t8 z6JwDl1Jm8Aa#8gvak|Z2kKvH9lR(y1MPT3@S};IZ7(SUM;94*f)JW1s#^Yfw!tEN0&Ir&XOf`5OCq_b?rZ->)Vyblb7e;ODphr3FCM?6_twTd&&Ej zJf4|Z#dDDcJlBp5cwu3pqssuGWPqq`6fe<)w$4<2ubi4aek+>@fnFZ|!h2ahEG z;BTGEe5TGW<@wB7<$0gHK31NNsxo7wwdx-)ef8yx`VyrY>wLh@jM-;v%`H@A7TOm4>Q4`&h#Fifk95p#qeW{(fX?Iqg{IImN8yl`HJkG+hquLI*B zaES3CpJz_lQl6UWu3=C|&_5L-Pm0f)Y$fqz5BuMM7}=w z?JMQ?1BLLJKS0kARmUUhjOriyI?;L6|LVHl|AqWMriXy3k6snJ>r?arw)YV%A9J>1 zjvn4x>kH{E4A*_2KLpELOK_^Yb}4iI{C*<$>lq6p^;%t<4c1W|j57X7#?W@?8#kiM zG?p&l>ua?86BqO+%0PROhhUurzgOmeX4hxQE6PV(|1I^nY8llXyNzm<5N+J}Ik!_+Rq z#v!lZi$|y}!|%R+4$K9ggEU{%A^L_JbNZw$wGV=A6Z{U_ad6)z=9H@qbM^We=Ej|K z%%v+Q_<3D8z#!iXrQO4Wbo_rpmW#D(%J-qu*_=gR2AZfxQjx*jQpDWi+Goi#T z!@j}xn{HT5{Sfd*i`YZ0m5ZofqqTA|qec7+$c$iptA7Xn&GpyPy1<^Jeje_2lm_R| z+cObYTqzG=-%WH>8O>!2sa;09|4FU{%R##Jq<*mL@KJmSjX~?ksFRe>!J$WJ+fY8r zbaL3jY$39Rwg~3)sqZKU^ip4{ME#cCJM?M(LBUSMw>iT13kY^VKhPfZgb(qld~15n zmW5})@6Ih7sD4G>@MU6dx>LS?%zv#Z&yW4amW5C7)zUNouMvG>08ci<6p z{1$nCX`Ozg_p19tb$t&%OoCkUZE@J zpAPoKSoqJ}&I=mtMid|A1{l9pDH56TR7I(5p6EH`0DD z`v25U!&ZO6rAO=z>R?RhP+MJ%@~tg5QeT9v)0cdry{I#22OfU!@=<55{QoZfzZQS+ zL8;7D_{{J%`4^Ez`Ws}jhTjCz!|#Lqe?I;X5>PaRv>?rB~9AhG)Txot+ zuAQQ>IQZVIPY%Byv8~HjPgSPJZ?U&MYm6~|Or}}=^YCdn@$2_6ZlvD}pA~%x)Wy-> zociZJz6Ys48y)LL`srr0AK-RoH%Y&{GCle`aq(WCRX+Lwh!r4ih&~zmF=zwYh8SdA z{5e`ie2Ne4Cq%@r=Y(9zM_AC7%KXW zcklT!VWj_y{w(gdnQPHL9b(X{7br4_Wq@xVyFSM2ntn&$2>sreICt8o1TE1H#FD{l z^nZal@Wg}km3HqiU<@=@&^SEGuMM4RDp&9~#G??mb==R#8p=z`2%!D?6UTN?S#WjP z$($oG>g4+YG)@h91vX7LuBNeCS{Kq!5zO~V8Mdo5-|hF2Wkttl;A7knaayo1Ei&z$1RCFBGcKY7BM;t}_V zeV~4j5#YEkc*52TdX=Ru{1f!UAp?H?j?CRuUmC|?WfmCb`#xXJ2e#ageX@N~O+sh* zi;y?y31Ia&!OF+p`xd{md;(^m`@b}HP#1NpOP<$7eYiYo=l_3CI#mk?eD_<#DKJ)~ zLaxd<#qZ>%+TZu230GGA!zsrTjwc)jz7GcAH+-+Fu6YVy8@@W`v|wJ!H@isw7&H8w zeqC+adi>aS+Sj{7{PsI37ryBQU!5Q0__Fb>>g|EA3!fYPfbE;L>3A2$yuQV?gXE7n zIA{a-P#3-~c!~O>f4O?0;z#?^==*%_h5XS5@VKV=SJi1-n|rqY=oh1YwRz`D(;$EF zmDazG>Ce^w(|Vqx|A#c<@c$uAJ|^EBemBl(@XRd_i5Q7%>CRj8dFXneiz0?QnP|-T6`ic;?RgWBVEShi~pEU%ELes z)Az44{|){p#(_|O%o(-Xw4Tnj!yG-ts4-@9FEfbF?ZZ4Xj1OVF2y?(N_Z81D&W5>e z=REh(`hP5+lm~%KB#HB*t#v_v_^*gvBflrFqM3USgP1)#4e0p6^;;fv+z0VwjBj8p z2=o1Lk2YMt=|SskXKhH^gSn4*hBlymHRa<###O*ijGfel|BN|*fn=PMKOfSD0Y2_! z2GeCAq9nc@U<;xOikv>3y{bBsZvo>+f?ydM`vRmlf* z`r7(~_89X6UH94;F)QcIB6B)zC?9fPUZr)z+)VV9Fi#V60x<@LbqT05e#0}|<9B*5 z@T@XzwPzSV#h4A`?o+b)splA{##~9@0c#zii5y>xJ4=59y)k}?vBX{5^+{VR#_GtJ zA;wN>J1?cCI(*H$)$;>3zbV_hxh z*i0~uGu7(d3&KX##TY)u)gd#$*{AAJC(r44G0`)mJlJT+EXIp5p8@**(TmGe#<5-v z>)Bv~!5i=nb{f7D>W_4g7hp4t%yoy&u8XXRWoV>$-hzek^Y=w+Ol!eeKW9qrRj^Tk1<)xO&t+X2H>lqZNmN9}@%B zrUP-e-e0~`i83~(6WFu-Bp-@pLkD)s65 zUNpeC1;+0%Muf4<`f%x!@z7803sV>L0l(31j0Ip^1LL{>`bFjeAjXaPrQezch@WGB zAB=5aY!G9|bPO3|#sAiI;UFD<0S$nIZyE1I`>|dY<8Hgj*fiF&LLUCzYaZdlK{_V& zwQ~zTB_p)`WNj_RS}_l$K8&Y;_P}0U%&p+s|IfYz$A2RK9R4}^=ky<^|2g}|+5dVl zpM>*&IRB6He>wmEyZ3+UTmQEX=GND?|A+Vo_BN}Gf3WlH1>+%=zg5IWNFSZ1M;d&u zm|sul=hyh2Ky0NlubO|u98=6|{`URk2YX#Hs)H>`OBo<*kcTHvoX&Wd%|I@2+o|qykK^JHnbJ|H`ZQ7*Ee8pC+0q5 z?%A~f5_4C<2h4TWQ(s2r zPA{ZuIM6nn&4GRLFxMMxPS6V}&3ROAM%X8hji6{BY7e}*b{A>-$UY|pR!}0`yB1Z`WURs z0&jq8oCyOQK%YSd@rk+jkbmqmiM<4MR;y6ChwNhxHQk@|3-$%B&3m77mc!?^AL&5@ zoSlGnf(F0=4Odt(@$Nu(c1AmVE_gP=`C2jntci8p=>sS+p zH7{7BgZ(R^>#%+kHXbyAp1=ja1o8n}28`ki3Yt~W!SAunhz>>q3b#-}oJF4A<6Bdm$QS{%?0y0yO30`14xC)NXD zebYnIF0>8w0N$~W4%Q374#I{(E@1btpDy-A#X1W3MmYBaejxNZumQV;^{QCoi}#@` z=sM5uwcd;Hs-ps%Yo}-;O`wz54-vKm&OhL9l7tj)PfzH92Z|Ds8&h=4eu-Jf3 zLp?E0jCw=o0q1B7uz>c#zXR6c7sH=`p9`CbbkM2jSAq8@U9G8X@cW8as4m6Yap)D$ zyDs)D{u+7s*t;w(@SN2dz&rR2n+RVP7+~!s=mEXqe%6k*3;adwEjF^Pnc30UV(Hka@HXzSbiWL%`k(*mn(dgKb5Bfj%dFaL5&~ z1^QMm<43t?vB9plgl$Fq1Z{&q34as%5B354#$Fu(WKRXe5s>ajX=iQf$P-Rx&UKfwaxQK zP6wRYypP>4g(wpI1F$Y;4r{pfWrWX0S*Hk1~?3G z7~n9#VSvK`hXD=)90oWHa2WV<3}6n^&v@1QIca3RAUoF->vw;ui>#^mR_g%kO4hOF z^~@=94iwg{lXaVz|NT>3WDPdfzu@dQtbeSJa)9}xSYys#M@!bNl6m)7d-OA0Sa@AY!CD7k1?R^)Icy>O%Ki8k&Md{cC-4Am!}_lJHcuDprm*%B^vBxt z4P=e-8nUkev>3bHESem|e@s(f`YR<1gU> zNB{3j@9I3_=+Du=Iwq>$SFyjG{{Qal);RkAyE4krpQC?uomf}*oc;ZGvBlA!qyML5 znbZIOE=D-|bM*g|jDG2JPXGVA*y8BV(f?Dj%jy4r7b6_~Ir@J}M!)nqr~m(5Y;pAG z=>I9%<@Eo*ixH0g^@#pI^ZXOc|Eah0PweSF{XgscE6nM`e9C%Be{xogkFNurqlbFp zJO#{G!kig=(tEP!E%vAw==@tU7Z>~MqW<7bm1iUk_NQi_A)VlyUF=PrL+1UlJjEWh zY`%ie@Em(zv(M=HQH1t1U-EvHGQXfcIG?6I(;xesdwbhc`lFtA$T=F=?-={J;`|z% zi-i4-!5_hAc#gf)6H@)?Gwc(NGlK9t&ZfcH3OJVpdq(421fs@E1y;H73UL~ z>#5Oa*rOf$^W&^4yenAd=hnx8oDGlq*XQ|fg7gO#f!XLAZuEREoaYYtz?@w00QA5) z7C5_$p7l=l-^E_**pnRm!5K%`vmX5(oY7#ezn1P%jc1UR<7AJ2_G}OK8RZW-7XxR( zX|GbIb5C_vD^ngo2LTg;WqwY5FrPl;;t{IL>Z|^N|A{lPSp5SYpv~CZ8#Dqf1>1~t z>@&J&I$1MQ$pdoM4tRlkx{jtA55Pn468RxtmIpY0gzl?Oa1Yw!nII4FoGtUS>O8jB^>XEk~57sPFtn#dq2jt9{H47DqJS?VjvvF<=>Q~8w&!`XErus&IU>D~X@O4KN zJqrkD<3NXi4lEC%i4NaI&eq&P&egLeb^+@c*k{=D{eYb*eTL`mXUQ1|#14Z$$Orlt zzvJwLW6oCeED6vNJUHoULw~RGjPM6|#d#ypV|dq&oG*fT@hly`q&|4JzR@3g2WOR zAWnqep;&* zy_(CsNa`I%&j7^Q614GCF|lv`9Cbin5I#5TDbC!1Kg03>XDC1y0pF|-kFzzd#kte- znXq0A`e8dcy9hc0XLi=>%W?xfH|JZ^wbuEQ^``LkBFUM-z(3B)#96N_51@;HalRhl z>l>V>3mE|ZZzp;)=ZVbNSsPZKCkA`Tu3KWylBrjp^#J#R{DBPY-(^Dd=Os#4_!f`@ z=pvlc1-XEJ+G}e>{dSx!5>NaaRv+Ox^fJ=GUf^tA_>=Hi*z?)`E9a01(zUkVL4ON! z?i>28(DR@tE}V@9n_THb61@aJ5og39P64~g+Cun+NdJ_ihy6s10p~}dpM~@|dl3CQ z*p&L9cWX74|G~LES{(A#wp(!r5TZQ{XGk7=@m~*@Uc~s*mf^ zzqJfNADC@iLt;H7Mh5*u=*!0~(IEuSpgZ&n@D9Hd{VtqwjWd>Dr@=p*>5BEWu*c{x zP}v~*4Dqughqp4`gx8NhdrQYzY)Bn zj|smvGU_CaO;G($bPn$EJ8TQ&0_kyHHSOmSIiUMw5Sc@Ilp|;}P!F6#i#`h*bN#7p zMkOyu+>_W@_%5IUaLM8pvPxE`Eo(x6U3K?Pfzs}@ky)O3~D!^ zuPVkJ>9`ir|LmAcO|k*|f%8-03xhAl}ZV%@+x=nJ~@d9;-J2%8Pp(QyX0zsJf3_=Gqo+s|P8eD(D# z?r$MOte&dbW>m#3<~dxsc8d1-5Kl#a8Z>2X$}Q5TfG-AJMC}HZjRn+ZLpJ#1JbZnG zxD+nFkHC+kz&}Hu9sNDX*0*SHea@5RMNKv%wIb%k`Z{!ck@`UVaT!1VLo{BDcq%UN z6M8N%*opeP)nx;6hYRso|A0eOejpdfALqNXaoze%uWwnm&#@WMIlwZ`K1ZJnW1on1 zLta2r`kdez&je)y^~WB2!J*F7ULY-GV4tlqm7j0fz8@;@PuL8MiGg;Ase;z9!MMkT zJpzGm_@5YW0IuQpKsK<48s!nOGk6c_VGAG^ki{R0M&HqYWAzkt415>ZU|^f|Z9z-e z1ke*P?wfbL=orObGA?gvqQQ80?4@*vPldGbO<~{h4*US*%hA4e{y2Dn{%w_b2;l|B z6hK$lT2ZguPbGYJc3Wos>0~`i83~(6WFu-Ae!vKc?4g(wpI1F$Y;4r{pfWrWX0S*Hk z1~?3G82Ba_;BkK(1vmJR%Xj_oq=4_)}P z@xQwMhx~K=|1cKA@&7}b{LpsqGxfvI%76H;)$Kng{~uzV=;pG3v_y0NjUprj? ztn$zC|G&+D&i-@!=ls7<@DM=lK6K#{W3`Uprj?tn$zC|G&+DuK&mJ zUvT`N8~>>p51jt5nJz!G&o9_Ej{hA0e@6eGv;Vcj_0KB*9RL5@{O9_A9RIoa|EDp) z>HkmDNYG#Y*}il9=lK6K`v08$uN|&`R{7`n|KH|6*Z<@A&&B^gjR8*of0{-=o6j%U z9*+MU|9?jRpR@n9!}ZT9|26TSTmSvxdiVdz`fqOkhnn}i_+bBspK1O7s>O8QCwBi2 zZvU66`@XRI;8gDaLiPdrS@wT`{x{HE!NlBjXL4Wi_kW>#+|;xO6n?Ae65K1Qrss8? zSM~h9N113NDTk%a&(8k`Pr~SaV3qryk^OEmpG8#C0Ca$!z&<$l1onwb%MPJ?$ly79 z-OnX?VNbb=bVz^yaTv+#eHzR=gq&dyGNePAv_~K2g)-SRY&z`6#g@f>evA5Nh1#4%h<5fHk&ElrN|U zkPiFrf#+;qz!mN*^#F8F9o9c9KZ3cHaJ~}zKa2jSyi!}rT#a?B;{R3RfY1=M7sNo# zdcd0jFIm1KU+`K`4*(0e2R2x~A^it>fY=yd09ZphTvhY{;Vm!#UW4c0J@P@i>Usbe zsGItuOuijL**^>a)t4wSN1d!HaQ)sNtK>iYxSHf>S%n^`u!lA30iu^TtWlx5|DW^# zbkO!s=>d`#s|TRlQNK^?fr|FjQT)FfJN|%XG z@u7SLjbUS0TR_v0GL5xYF&8hnRQdx*NA-ZICcj=JKVW*>Cans)@*V@w17BNzww*t^ z{8x!*6B?pjcTz4e=e_nbXUYDbfk95h*Cl&hR_HuD2mit&PcWXI`xq~h?%cV3jE|24 zlYBq0vi}8sq>#QI-p6yKL;3)6pZV-kWjbKsGTC#KO>^!X^4rf`zvV&8Vd)GE_z>R< z&)B>!h96@dKIQMl%ci@XglSd!F}~Kf|z8}lGQwt)4w z1Tg^oLsvsLLoa)H?5))0xc}KN_&9>J`jNhaukgJR()=j@!B;28Ei{fI*belo;12*J zg1pCf*l*wmd&QqQwTs!a!+`qd&>5_M$oh3Z^)4&#IvD-zW6h}%Sy7{WQ?>tU=E{XSq}%|b<*CouOT(&=ZdGx&_S zIb!(WBXm6aQP2ah1@H&p6GGPU96tSly=KgDG6oPHd7QZse~$LgSiJ!Miyc44*baOP z_?L*8n{HUmkLQsxIULjze}VWd+H~BDo z6X40pY(4cE)-B=3ZaLlX{d5BzFCj6rqzu0P2M^h?b;RIc%iW0nhM%iJVt9xb!Dn!^ zH>YtGHl7PUgTL?v&${g<<8qpGZ1<$AH5os2ViHn)2}UCL7@@X=uTwBK4BZiP(}Ro& z9HzEKUwt{%Ep%LuvlZXeUjmJ7wr;G9?|_d4&W8vwk&7d&+e)8At~R<`_M7ycgB*~0dxjz0x-aiHvk*ZE$}^~V$RU9 zDcA|bt56Qr4Wx|EZ7-L;2HQgHF8XS~0QkVlC3r#YHQ@u|?2uDD0}qidBFdGq-(yPW zbMX1i&l}${zvnRV7mz33;lIn5dDs;vhb=Vrgt;K_1&&fEqP!n5{#LKBNH73B0G*3DI?%xwr=hyJqK_}A8wB}}7$R%~;yd6cczZ4G zEafAMCA^Ei{6HhenHz|IqZ?WtF^{XzOQ zkR|W}{W{ov^z;46JPnNHLhn=EO=OvlyHxo3z(^v|<8+*l#C&jWFvShgE{J(k zh%=}YoU#0&a!Y7qVYrT-HJ0)qkm?oq2*3^WKjzv%&mhjgpDW7OKh@(5c<$|efc8&e zCxFLF-SC6Zzdm7>uh04R6?y?aqO+qVCV0&Ij+nL+}zj1#iLQ_x=Wv zd4d7>2EYWcLH)@J`wr{?L%T>!GAibDr5}Q_Slm^9vVIG}5wHrKgcy~bjUlyhpt&Hv ze*_-Xr#e#F5xzjjLf0aO1KrE&Xz+yk@{q?0{y=x*IdnO6IvY2DyaEHz{Wxz9w%|Q~ z2%ktfuPyJqvH{SU(5qs~@z9E0^+SY5#OQ-Cd$d6SgwdVDAC#~P6NNU(KO zFL8R|i}WIFIq(1;VXOyy!q^YuMuPLh!4Ee62cHi7V=wr8@crP!SK0`YC-4QI0pn9x zH^O2Hc|o_J-+92!jFyW!uzm~rB)}PP#g@V11!V&VSQmmd1n_m?^TNix#~o)QzDVB? z{;|3NeKYjYF!qB!8~SeV`xEc^4?d#LhrZwOW80}*VxA!4NbnhxiC*CIj&C0jXO1QF z{?WIrzU~J09rmd@|M}R%{09EG<&zrX4DfM*1=JT9)>^rk;(^8E7t!NC>~{hOpdD;D zVm4M}9;T!HW=dnwn$`c{4co_Ok{BY!p?x_z!uw&gswpBne`25-;l&C5Mu=25woWKK~g^JPrz@%+$6#Iqb%Mi zCV@+G)ij?0SmX3T4ROR;;)Lx_u=W~p28{h+42a5QWq;zmj{raKE*V1tpE0HcEMSZY zV@-%3^T#6iJcAy=oL`Jlf$uC%C~ksQjg0Q3y<1m8b8Zb@(iI{=%+jw`{B#CM(5%FIy`H$dE)EeCoA{Sl7;beM0z#7>jxzXW)++va*ZMOwuPnI`|0eJR|V3#&OGPaVqd1 zF#`Cl|5(3T`oUZtUymX14t+t)9fX~zK5i)(XJByvpC1^3EW?%~c3ktgWdL)9%!fx> z8mA`u0I@<^9>?Q)DFeU*tDCFF8T@I?7kHp?WFp(F56OPM$Hx`wKOn9EJ%Rop{6563 zkSBZ+PIkXucE2?Z1>+1D>p>p?^NcEW(c7HGW{z&Ce-B1x{z#NszaZ3{G#U6vu5j#no;74i`SJy9-Q(!@G+!Fmu>~F)- z^ULV@L({Dy&hSIGf$OJovddwB!vKc?4g(wpI1F$Y;4r{pfWrWX0S*Hk1~?3G7~n9# zVSvK`hXD=)90oWHa2Vh)z+r&H0EYn%0~`i83~(6WFu-Ae!vKc?4g(wpI1F$Y;4r{p z;Jadg$Ng~>;3&XRfTI9M0geLyISNRV7Kf3`tO1YLfG5mv|KI!%{4ZK@k#$NuhyCWf zYd6mF$eQK%3Ak?@1vmiTm%rGcT2`cLc0#qyzu`B|pR0dW z##F!K=wF>8Rqr|cRHaAtI}Sh9DN^;G!%tOuRKMf!Q=KAJ?>YQbrAPHU4nNf?QuUt0 zPgQzUzvJ*zog!85Is8{8Xh!^*atf)hSZ- zUTn~yF9?ryt95+ri{pE>j=z0r0I@0^K6Xdw^M!vO%dzV3-#ULne?($QG58>1M}#X8 z)Fts>d`OU6!p!PvY8{_M{VTFUM44oT9LcX0Iifg{S@krv&L8-xUb$K{pw{`5nto8Z zS~Q?r?c=v!)By4lsYNaR#7N37X+UlBCk!QjpU;6><_~D#@ADy4JNode|3wX`1@S4e zp$i8MYSX4oTk;`EJ}W-R&jZLE`4~t(#6Cz|t2bKwSpMQ6X z9Ny<;T#>pq^&d2-Kazb3fdJ`G{_(XL&3_P$5S6tK%lX0uIE*IS z0^`heN2H4UQGLn)s5tyd))A4Y=IMsaX!fGK29+g{-@g8j;s@D?q1F=k#wxoyArdk} zM(?v1&_#7XBtHEmY4&{BP&#!Z#p3VlHHH|?2mkT2r}u;nbtP_WMtnOUK&Lw6vtUNF z_LR%~lF+WN<}=%bfz-eY(h1pw?&um6)_LX$1Y}=QS z=KWh$GOB@2btnuxf$Z603E`+Re_x@`|Hj(kf$!NsRrUb3kgryHY(~`VlM3>){|3T< zUd{XL$yZj+zZR-|+7kj1NKz942Y^@kYh5TkmPnFd13!C8r-A%7^rr=W&42HOK`?tF zceLcM&tHHXlnsdlE3zB{|Hkqe z*}tb#?K;=BD)l{|1+wP@s#foRIuT1Gs2)ljR=U4y7|gBfrMHJjp__O?<>ce&w*_S{@?#xhu_{4sZQY|P(zkT-ztArWAqm&vyQ(~ zVfY5@eP7MC6;9;)U$}1^1vmh$tvE~2%9ygOrhaq1Z z@I-{j#~o2QGW+R|A^#gUX0~2R-r+>8j?oS|gS=&?OY2QDJFeQ}nxE2|!=vx4zwzwg zVJl;c0LS(Ra*~H8m4-N2ryaCTeypl-eE-pL8a(qC9xBaN3@kY&FDfj*{kF)`8T&3; z**sQ1XQt4N*{A%dB(C2Bxw4lRdCe}LKH?VIpjmdWqe4f9{nm@O-Q|b=vc&UkW9h^e z=G(TsauyZhl`eecZY(mJw>L&XWK4^Q9i3vdWn$zlL?n~?Z&);RaN6MOZlU3=H#IZU z8ZN;T)-!TCcp~87iR2fPulEShS>)ONOqAilXOG4VaJ=|r!8_4YJZTqE1rgu!2I2Fr z_L0hr|sxS1MXVuJ1$n0)z z(*_ozLaFB$w0ix!v&_V$(p}aY_OM*h{j`6-w)Y!#S*Fmxr-gW61D@!@W0UsJv$^wU znV)Itn5U~-L@a#er{DS%uTyIRl>N)*J1rY-v*e&^FDE6ZNixS9-jawooGYn#Qcn9% zNmq5fvE}>vC2Ukye=@42qxeGS0cOp@3gmf`EqZi2ogENqeL?QmUlw+Y@!BuWv$(%J zJV!|6N?+U380KwGMtNE3vxM+Je-kD}35N^wdfyJWFq8jX{C`mq(Wg2U>AySQ>BYp+ z^L2uF7E%vHJLH#%DrlZhZQ4fILPgBkV{5nH-^g}~+pPQa^7!cuj2;!W&l@nNMWOI= z3(#3@kp7GlBU>1AxZs$-fml0vEWH&qHX0R z%*`h)7q?uvH1b^Q**xby7NR1OQZG8i4Up;XzbJakFHzcBVfG6Tb+&mv;L81XCUQp- z^Ri29&pVbHOpD1|(Qx-oJEnMwk%?vIa0QX2yM|_Im&(fM9v*RbL*U$Nh7B5I36Bog zHL*|JgjHhcM{Ki)i%2fLX?VG$VU}59$D$4nPWNJc9sAhiXy4oVVENwmdI};EJO%NC z+br#FX?f_~%_u)lTGw-z$vgA;HqE76Zt2L1Ha8SnY&&g#!^M7y5j)D%C-R2%3!NaU zAUv&*xBT}5n?xFk?Y^fh*P>^)Et{83$$J&pYxqc=aWe~4M@(%be{f*Ls5ifd+LtZ1 zmk9`Qx;lCAp6yLCm-xxFA$Ty`)yD0T#G*!REJW9wNm%W>SIPVCbYc7ZE#DFZ`Hy?r z{jKvhub9lKp6Mcym%NgfyH6kAVzcHqdAxxT@7(^K$Go>~^uAp9?8V;?_xG3<7PBol{f7gk zByW~w?~*&rqQAq`$VYn=jp7PAMR>A^eyNU)I%8Q-5M6R^UY(COLbcR;Jv9SKCujeROMM+7upE+SPlO9^G_?OboYZzvb=(-9627FFjblQCrSCz%%~0%<)sLBzDYObf@*u;(?B(50>k^ z+2NDD)o++n=AwtMGrRKSMIQW>-m$2udYb{h&&zrJb-NmExLYDmZO^#=d$#|zN{qae z=T_RfyzuoSJHu^TE)31e5$QtsxSX7BiF=AYuNwPocBGKXy{v3%#H z({1lg>ANevXZm${XD4|ian6o|nPrLhCi>(z)f;8Vj9M~&L z`|@N-S+Oae*W+a3to?OGH;eDjZserfG+0_DCuU#f?R(D3r#kOja`2W-^G$yaee648 z&B+D56>}1Pwa?Ee5)RJn-Xcny=)RdfJ!f|mO-d1;(C9_(usChGC(&LqY6o_C-8VV3 zCUoJjtJx*m`xf3Bywm2k$FIFMAOE$%@k6~Il{B=T=`;Vdp-{SJwjsMHfTB=kS-NG;> zrl~}Ws4FY&)IFohLnV5dO}msWTVh|7)neA@Ug}K2kc+zU>vYd;bKiF`+)Y&8Y`UAr zS*B=dsjYRE!kWC?&GChuq+YBFlpz{kUZnf!d3uk!ZgjesX5_p0TGm~gGina6eDCW_ zJEsymZJe#!eD|0!&iw~=mEaB7DRn>lukhkB=Vtfsyci%`B6cvyM|9`bK^;pkmODRJ zYMC8u)=}tG$%WLZt_PyyJo}7({nSogCi0w^iLurF)Rd)Kt9|FFg_f5mmpgVok?>4I zvqQSrp`A)2FAsC-cw_Hjqg0%4( z)JY{8u8NQT`rn0~ze_3eUL2E{*w;SFxWsXSwx2ywfOpn5;H56M?KCV+OjcH|!)jp> z$>rjX(@guE;*A#S?qt||QV;!6Cx!mL+_yM&tVk25LmRzUZ1DHEt@tE%{AtC}z4mHf zj4oK<8I~&=50nreE0TRAu6?Uhc{XyLe(B+tlK;z~H;V4tZroTQY0)BTke=CgW!rx{q(pyI-KMH$(hc(=O9S&&#+J zA4~8)yJP1QD@ML9MIAwF>k)pp-W>t4h-*UK?+=X zLVbQm5lOAEk-BOHHxgTy-I9pX-Ze(dF4T2Ne6xKyOu@_Yw>C#}16>;q94_sZKFCLQ z)n9+6?_wg}_O=jRw)QvIqq&yqIu7o{MqMyIkd`v{OzaqwSf}X?-sMP`iA@+)vR|sn z;X7lZ#^HYd`kr@%`ekQj>dzsZDu<)_tvTkp(-rSpHKFO#w#?9b#WI-p3 ztIf7IPHa2b;IKzZ{#oX=te^JdmTxph1+;hCMigR8?bqw(+hqS`vhkJWz1}`+Vk7*5 z4#t*^4`|%RoYz*XCnGv>oUDn4&Jam|3tiuavh&6B;-*dXnK?xH+9St(mMOWtd{(+8 z#2bW|$gX<6%JibU$J@qhW21j>{qorT{XKX`7wk`co*nS~xnvX3ON;%Q5EZ#(`NO|O z)*GG(@HsKPTd#>8M3>!F2$(asabur5L&u2-O&{L3-5`DYtbr{D?X?ole4sj_um3on zq-4@|`^gc%q%JjB(M6uLz<9UHn^W_m+v!YczwqH}Cl#LL6}?MKe-Rdyzv-NKY^wL1 z-m}A0OlEr6pMSHoXU2?_{f;7IhOJ*8?R!&Hbzp+S&?xN$2S2&CR<4=6Fx3%VoSpS` zjanZ)(PV6*`&qlAxvyrn8!&lThZXwj3TN7pZiB)p%>xUV2XAgobxm8FJ1RClR@=Uv zWLV>=ed0#F>!oEL|O4 z^@AOg4T@JTH_>uGDs(_tSXfNs=Fkg|^1KF4kcnD6Fw{?Wc);PYClYS{Im*v5uh%+_ zt8%(8$7`+bD=W6)fBD;*TUn~BE}D~AXtaQ#|$9q1BBW(>(s1&C4;! z-}+QVNi980sX#VPyY*3@%f}5C_R87rv-EOdmg<_;%TmwuPU^33f4{&m+id9i?)w)0 z-9)U7t@8L5=bg?=X*85H7Y#Ewt<-Oh+T|zdF|qH;XZWR!vFgzJY}1~iV|w>*F4Zm9 zapnE*+-{`x{(VN%pC}r6&hHXu zclB|K_TfuDwwDd{^QAoWm0#x{QIggewLmU?q`Am|7UqggjfFed{JFtJ@$Bs8+2aEg zhxh&K&bCPF4$8kjF7_Y3$uD8Dh-BpNJf6ldQLC-10!R7DM2}H^bL!?A>AWkskM`Yp z*D!eR3?02Ggb@RSrTpipB{a)k+Ee*PyCz+7?OvxR-0*E3wnXT)w38vPXOq?z5{=(n zYM?yqzO7}E)98mMy!wtK-5#PS=dW8nq{OcI>G;BhMx)#9+}fz5!}HPMNjLj1y?MLL z(JMJJEGT20Ps_CaEwxWQid$VC-fYvAu1xo$k`{-qPqQo$etKqzUZApPH@U86nFspJ#S=-4$B{p?=g4w zn%GNY)Stgn96opO2C-M&kL`AhJQQZQIrPP3nZ1Xer8N>8qQ7xiTN zYf8^^zBePq$Eb&?p3a30^Li^5IVCtdx5#T6`XnRIt}vjbX4e?)){=u;&RVCd)bhlf zxk^%M>8|P*9Hod!$$T08c&$Ql={Vb%!rSv&1P|V}Md$Z+x-m0XK0PwyjPH}}a~8aO z)_CESc)0OIX7)>xn~rL#Fe_1Q%aek|J-xl`Gvaa` zR;t?Bw`nRVBI!5RAt*qTSkr6x4G%k|D7H*HWxr-?$I{&FfGvh@o93xT29e%Ql=h*p zpiwjRdOG+Iu*-ZQHCIn;v}3r``d=(W+KTn|89#ik%!waYe@lql&d!+_kaoRbvEu1J7rb&!zq7Tu$vlVYBh!dinKdbPqPxol zcPr_=JI{Agi(S!2!b08o_cxXV@-drxi|Ld%xQQ(b>5}Ij-({Y2$$x9~AC z>WvCW5a)Km-@ROCcQjuho0+!tro*@vIbDTrP4EA!NshO^rd&ipnP&L|CByu@ot?)@ zSWNHVX0g8i#?q%-`z9ObPrQ0Ar~J@}1IBM9Z=SM$lsbOX%`=+=3NJgvWUd_cYq!DW zr@9p9yLE0SDkSmF?A_Q5A@^b)&vi+w;`lD>x(;@;jtkS)SoKWW?8W4wN0~u>PN`z4 z4=>9xVcN?5=JasRAsu3W*|q@|_C13(OdrwfP{$~-FwGV*>o?7NTp(9I+-ukQ1gr7) zS}F8SUao53vhcZYicHiSmoedGCU2TpOfSE$`ZT$t`TzcK^t4|?%$M4Tav9l@jaO{C zuO58(!2ClSqWAcOYw*ThRm$!(|D}oNhy&)6UQF0r&~XrvB@GjkqrO368)uABd2E?* z=Ymu5(~HK>_n(NgjxUV(GdgE@X2S0l+4H9ElC&V5nwvV265)B~?H_fWI;te$u_UWxAIc8cO{;vqC<_Zg|+_4aS(>;95DKm+{<((%lb+8(v*!lX#Maf3RkFxzP z+9ZaPU{o7Dp4q3o`0iD`dB zZOPHj16wxa-8>tA*Y}K?k~&d?#y8Ink?G!2FK)zvJ#pGOPn(=Rkvum;ZsfTqD(3&o z(TwdGvgG0Xs1hRHf6%{@;=i&+FeWErIFRHw}IEO zrO{H3ft#ibk`-Izf05X??1i~`{rVX$+P6epGk3%RQOOxvf88!H%#aOSzv_)%zXeHh z&Eg`Bc85J4zEH?kDp@Qn?5O5Ixj$AkCtZ{$D~HcrJ9L3!qup)dMzzdeXI>`ElMgz5 z!SUK_#_w4Brx{HK_+{C*Q3!OkAE-YiuXAYfy54;(m-Icg`45AIR!bUdx9I)ME4cLN z^1+98j$gSnR8_O6Q{R_qPNj;AW}LCUed{!@#i9u!GgjHG^K#)$P8+=8W$^a>zn+{l zpl5i`@giIH+?rr&*o>EC9I#q7@KrC_gR{B>C!c;aiSQt4?d^_*>sOTxPl`;64<6$@ zG^wk8U+q?j!f|EElIGd|Zt7F7{Y6AQd!g5%-fiX2PBF6&un@g;ZnDI$Ca)8v%%yi4 zZji7zywb2FTXE$wxgquo%L;GY-t5>_&$PXMeyO+UwGH7mLN8vguvNC??OdziRme+< zbXS_SccJ+taj%x%ojOh&KeuZ}BOcM3`%>~1MEcw#N}$?%=d-@uR6{ zeP*cHYIxpBGY&X$dd=M2c{!R>ic*y~WX?!$?by_eC-nE=!&WzX@?>KhEa>bt<4nT# zeS|V=rrhdNY0I_{F-fJN5l7Sy_8+Po9Y07%Nw?qmj8{4x-1@0#lx>Ee{d<)?R}CK`+OluzqR=C)9z~{W=;yag6F;r3p|54B9j239?j1f} z?srKGvDaA&z1A;VIsH@@g;N_0i5=_5lm;msUHbT~N*nLHs(GPFSJysEV`4Jz_Iuu< zZ@74K^@8HW)){WCTsJffu^Evc;CE(8+sQ=RzgaSZ&TCB);*#=D7fvJ+Yuye?60X)xU4X5Q`_@!`R*J9+5d4LBKImb2Yj zm?yGi_>d8fnlZQU4`|)tc)Y`edrguC-%1KlQw(`mI78CUF5t~;59xr$rv2Azn7ula zaA!oU;)K-=g#z7uXO1jLT{TU%goI+7ICTxY_hQ+Sh#V_xVR;ex7JC{lUYylrrSaN_ zii?OG2s2*}0#%%Y=QuPYAqm5b`IZ)i>&J)$u86)_l)*fo7U(*w!DOC-s`$z` zrzA>O_$4kI^=d%RE*E9J2m6&SbxEA_;CM=gVx!&~S4(c>d2i0kn_IT1bCV&P`gNKY zZ2I!9Zk*Bqvq_yF7yNN9)_R`M%_5h)u_w|y>FAhyJuA5(6RRa;dLv-miv&AEV#qe{ zQt#q7a`TA3#o<%C^UMQXyRPdhldk-i$Wk#LPejr@%VEAl5Mv)jdZUX+_iCXiHQ>sz ztiV@U>F!awn+slKmBU$q;FyIt{sPd5pmM63VJ-Y&E^|HFLdk*N=>DQ zuj&|aQlo3fRv|;$l76t`O79fap}#D;(O^9;O3l#fwtrxl&E{(t!e*LsLZhMe@-xACIMPZx(H9A1JHtCsXk7@%#i) zg*$`qZuFa@vGh)-MBbI8u1zOONoLRSwe90&&pRu5{!s6I1K&&^xaQ6EvnfZqTzvQ9 z`NhCDuhqS8pX*}P*VZseXmGi@!L*U-vGA>qzjQvD^-3mn(SzIXuJ)av@b`q(q?3?& zc=7Venxvg66eiyL?xS19W7JEhKYe8>x0h!op`)Mb z7aI7yASU5ancLc~3zqhyabgWq{nMvIh`#>K>ag*JvC_w?EH_pT)ecdNw&+;)3zl40oc=fGQ17B7V4yblyN zHYO3W@;~ocCltRNYWnV?a*@-J+ik^{*-kTgXKPw494w|=V0x|Ct4}e(Scr$g>y_~JKHQ%DoVJG$b+zL`is|1kCX^GnTP7Wj7e2joMm`2WVfh-S*IcrceL;79sC{74crnmfwK`{l!X8NC9I z9d!g(La1IcI~^jYnWFXC#+s}r;~+zQsl7(JFiD7cFkzn z!$tJy1Emwe1iZR*xNeY1`G5JEhT=FGwFAzUuxRKik8^a*A87 z?u`Jvx%62ReW^V*t?$|PQ&)_X@JWl!Tb}-|sGFbToRN#3>ZsoAmva5@#c`KH_7No= znX}Te^_9&ce_r!B+`ePSi@|5R*pnd-4`%a#mGg&;meVes*7{MxuhX1&l#L31^1$)% zE9dg7>Um==Zv4{fl7-kXw;Q>{V1{a{+%Gf}SrQ?gGqK~v&=li<)A5cGvu5_rpVrrv z1nWi`zHBV@V)N<~cT4xJ(SDlz_KJpQOu)kqhC>=0xPNu>+E=&o9BrT5{POZ`eBLs{ zXElX3~?0S z7}G{!_KA>J&tk9i>o;k_U%apvzwZ;hbbhULhq8wn)9iM5uQFLgg!E58NBx34jb!(- z2cvH>-5?x_T|CkZwBmY9IvJ9&y>!yuxfx}ljKRGpWfAQQg`5}uO&;nNlur?P@Z#9^ zyQL+IHAXSE&I?+uI9@VVcvKhtyxg))e;N7rGkH?%HEKvZvqqAUT7M|d+o{Ra#`Gk$%c5d4*t!#-&u?Su^sM*q8tHno}2Iocj zj9T}4&eN;=w;1-;Eq}K~uI;$7QDOaMTxLHdUcJ|wwIPb9cJAHjYvX+2Vu;Rj#|hDw z=ZE)c)m3!m<+6)RsVx}>>3`$(0+F5~N3TvUO1M)R(^?~7`D*{)Mk+}AInHMa^yTuS zRypOHPdYT;A*3YMKKgQzW^S|Ioiy(5d)3v+lX*SC?dZ;Dw+85R7jG^{h7?GpvyYTq zzf;yLB}ZxP9^Y3aq#=K`@SXDOp<|Qy%ON z>6+O0hKKP6X0S((3cy}(HDcX2J`}LEcRRL)`Q)X;^+igsX%z|k) zY7KaIjr4OaUvccpoa$sh<&K%?CcE}cB@c!5xI#!Q(lc#pj*9WBvXgSH(x1(1@#;Bj zP|KD*x`}0$yRCDM7#d`?gcscShIUZ%BlFjhzHEW0NVlXjL%khK4=YY6G-&+i40*Yd z$T%6-M5lCTy=SZcaEyE2^OiGH}GtH!^wdq8e<6AS8+&uj{x<5-8<1({r zKbfrqc9*5U@wvR)KO;~vvFKUC68Cuz^!n}nJj6J9f}-CsH2V zTf|ct+__`>xw@X?V+fq+oV})pGi<{ZIuSos9F~Vk*MN6@%Zz~lz?oXeSryW*{+r5hr9`N_xRGBE7RvE$vT#x$B z8JidvuFRLyOfnWMvxC(e20WI3SA#r@ghi+}O|-`3S)3N5BQ_YGQpdkatKRu8Ri zwmql(<>7BEA-YhgU$f1I%jUT{c)p8c%H__y*=~F-H`!QNMc*bnVB7zA`!oG{ri=F` z%T3a|Eh=ktBkXwZ@GNbfgv#)#t(;1?neh~^W@*U~7p|$nD6xwok~vDnk`}?zR_ciX zCvPv)N`G)}Zu;ow0_>O zdxOKgq+i?uqkLZLKkLhDw&Dz_rG0X1`J+btH?|K`k>f2YI6k>yQ1kodWxQXmnRW?k zZl-Xs7kMPnT7IkqX-N>TXLbYWy|$L5T7TfZ!Ik+8ZY__byx4}*UALz-l7GqDdwQvE zgXz+?>>~ zwp1yFeWjuj(zIgLUt#Nglvb?TD{Q^_Bx6fqU-|c&YW6>U-fd><)aK8cNeb_JTJZAR zT;qn%Rc3bK3GwkC#r{}rxJRuQDt1+SHOo`W^IdJ=~vZgunIw}a8#g1E|+u*`)6N8e?MH<}fI$P3$c+Ta;GqnaxvA0~?TsSOy zy|&P_sg~-qhQ|9&>>v7M!N|>Njd(=QdEa{?qTa?yM_0j0I$&J%hP%2G^qXu>Z{6nE z;CtCF=1UIToNC)AXsf8zn0*T$yef$^=)AA7Y+`$nqchbyt?T-m!_-DEMwc{M;(a^G z*k?{Z#VHZvS}b#WTx}|I^}NiKmiIbYnWZSkOxWU*7x641w56e(|F1&lBrIkZo*3^F`sUt_l(g19EPOMr?@Z~gt%E#; tW=PN4(#-6kDw*15WBDV0dBikqlJ`XM#-wE8`}27J`)ler4+RzT{{!YtveWH{ zbh6s)4JZ3U6`a-%7bU=%Gu+f&kr?D70B0^vcE~2=dA%d7F%+mS(g4N$i)4Rh1fd?D zs>7IoAP_5ZzXT!LpkLbB9UfqA$ysBcV>AhQ6hKB&QKDANIQajCuK&yg!T->;?IHLG z0Kmfjm#*Bj2^V!qoY8=`7a_|?aredobIhdBG4n4n7*2b?g@-<~D9eF@bW1g9L84eI zI$@CsvOlqElEXe=Z+?d$!oi9LRn|*eXj{=`j{jMgdH``yMrF!Vw5!{08jyGBFB?mwMSc@%o ztdczFdd}i7M$*5>DK9==P)VkmQ;k8EQ)CP@qr2D1+Iys`&%p83Ew|yC(Z9>{$6yMy zX1^Qv+MG4u{`_=%Y_^Fx?KhzPRJ3Kus^(fBpE_-;+`-x2H+e#f&hu)UEw+K7My3=R zjr7do*ql5H5e8;xz3YRX*J1r|baIVJqgp*tvHVIPK?q1xD{ias zIhV@A_ygHE;Dg@ra>(ZaJGy@urZC8ERUQs|uYJcs-({6@7@5i-IFF>rjbfiVx~ z&`B^s{j)-^i*N}23wt(T^Jz~=hJ>1`y>?W0=Q7~X>-CDK)VV`(W;JW)N4$d}|Lfm4-kM;jrkFrrKHQ%T$|fCxfT$uVkNs+ z5gFiC&V+8B1U8^BsXX%=j_V=?u$C<8BjTo3rh zJ=~I~Elw`R_iVyA@KP2;YY-#r4UJ12nFwZ&n}BUDFO~Nn=N;&=hahWH?Z+T8u0rn0~kt$OlG8?h!15uYB{( zzdrb)Z-E(pq-S_3livjN8bf{lfqY5k6Vnlq;(LFBG{~^KhdstPrN}7&6iKG>y_t=` z`Ck6yYz1hd`zZQwQ9a?x{PT&S2Sreg;kWPN?kI{8pGWEXfe4p~?Z|smCVlPogu2{4o#$D!8 zt-vuM;Hh@>M33ZhyDFPK`x6cuzh~FnZOz3$KT|}3_{$tRxPJ{R=&~vtYKVw!HJ|^_ zy?i)BG*U4}>nhzCX(EtO(D2DQr4NMk2HL!&2I8CL#GqNw!dC>>sFN%0ofZ=^(Z#*AjO zyg#ibLBGo@?|<8;a5QO-yrS)=!*k15r8UVsw$8vUkq6` zPikb@Hhqd>-!LgZH-81CBNdC0R+cd}S-P~{`;O@7BaJd8clT8ye1enw>gLja(zr7} z`f@o6<7vg~r4NfQUYAlmRSvf?Ma{fXO}`sB`4ojBS*On5vj^0+m7&4P@w&s=bViy` zkS!<7#3*wAQi@Ycy!(6?aD5>WzW$Q_%bYH=H&t93L5h(?DhLA@FJ8cn=R|@~iUUz9 z+rv@mUXT6$0@GdN4NF0lKN?$QP6Nj)V$8qjG^XJHGH=95O9O5Eb77<%B?zVf^t&18 zVzIn#Cq)pha`rD)Edy_dr+=nSaJNl$dq;Z;#B-Dx2C7CM6palJ`z&_*PrrJs)iac%N|^v{Pj}hJPigYx-+Svu5-R6JNw+ zdwU%TD2*R#U!KO5(7{J8j#qJx{xP%P^GcMp>9FFcN<}`WyVUhXopuIlU5e=GG4+gZ zG`F;vvOJ24G`3|=O`YfPxe@X1J8k>CJnfhv%n0p&c*TW8>Reu4{^k)|Nk#j!a;~mXH6!%*)KZ`9V(FYeSZxbZuif+(J)hUD4W^G!I#)DN2j>e?)#8DoO4+TjiDx zFiPHJ^Pn!JK&%Y)FIAn~tR>|oF0KHWp+F|eq@XYV{at7dMy6A!4;9AhlWqu7zp08W zCUV%)+h8TE%8Ns+g;$5?DTx)%KxJe<0dx&gu<^*;x9@sN?zWTAVPZkA2ji!`j}xgF=MK26QLJ_oP-jOM7(rfx)=JDPsW|Yy8fS zgji7$W<{@O-ILQ(YhS1T+Vh9oEl>H~_wK{oKa@}FZs}%EOs}%hMA(0y*0OEMwEbu> zk`*@R_wF_QaC&Y*we5JV-bf;9HIf&$>hic~+Ted!fsApM^Zo4eWw?U&=O5Q^$YRo_ z4Gdz^zYp%XMLYC6>=k}z%djysJ5%yc4Gx-u5vh{ccbx8Yqih~9HYMz-xp0hQE4r$( zLqudn1<0ygOvRy=bkkxaZ{{7<82q}emvYE-se-321Zj4!*FFK*g9?>{!$zQ&92CX4 zgEi%6{!XGE1&%mmkyOU>fI;F=(4aN=XCMY@__QuptjK2>IoN^&v^#*?URwo#682g< zcB)p;;}}nUt99>MTU_cCw)&8@lqRXY#f%q*Qew#eC(?f&p&eWnWskPlw^!OptU^3e z#UsPG{lQ@J_9+VH58#2Y>0dv6G+|VtkCue*V0}8v?!X6hygDs?z$qu%|6&FH1D4tU z#|mgcKScllpFaK93Y=&8IHfLojRqX$To(0MNz%dV1k=F(nG6Gn6?}5|axxr6uVA-4 z&lLr7$^!X~)!MViIa)JN>H89nl?0yB4b=K5Y%Fmrp2@7Wb9YiQVwg}PB`$U%j-ZLn z)w;KAJ>*{ACj1Dc31uH`J(kZ4C|LoYcU^+>j(T#Mm|{6xUGM!38Ne8kPl&7}h>2oY z;X4(}D8|E-*x>>0GS{hE!?R`^edVdX{&P7bFpRWm7AA~gjm+__ZJcJ_9C=6i_thM` zM6%|TjEEz2c(PcJxhnzLz~8xvkZ?I zJjgxRgAw#3l(4dl%yK4cJbvReZg;2)OWevmZE0p>T~2ny6Rj$$rm>VNiQ5N)AJRrf z&edPd{0OG>(!x}~S|78QRDgut?&22Imq9;kP)nIhN)tu6h(=VADHS{1x|?FsmUx8T z8wg>8$Z*k5L!3OT|A>xHl}xJESpilx4fe#PII=RW z4x7?mPtLJR(xsjz(@KA}TX3<7NX95p#EWJbgngcjj!#ed1EBX#+RNO@-Q40978abV zn?8*-sQeymbauY-%hzU}`u!U>HJ$CKEu%rLl3r&Py{t^Av)04AZ!(1pZ2Q&vMBOp>b zk>Z<)`5$rSTfP);m9dfTdg+jiS&XR(8z0q@Z+D_)20>~1-Az>MdEnRDgKj6=tZZy{ zebzj7yo7o$UCz0+K$n7TDdQ+Px&2+UAP0jY_!Pwm$|g&{U|d>S^;+g3&1jzspTx~g z8j1|BaTKxuJ8XP>yd5vHYtNkcc(e#m!G~L-^+kRndRMy&?=hqUq-@zjW(L0D6^qo+@ zrW3HkDMc|btl}=cQke~6IlfDqmeSC7D&73y3>dQYu?;_MfQSyQv+{#z0VAW%aJi>h5{X6zrVknd--*hb_+wIMk2W(iPtp}vI0fLk@ z$q6y!C=N>92-u*6qnAG!hVqgYgt$zyjCv}v0(iy3xuXOwd_kT9Q{ROjXuV%lfwduk zMx6wTD;>`jXn`7Q;njG1e&5?-a+3)U-g>JYXw?e++b*Shr>#x|pyy9GM%?&Q<(3`M zxi{-d+tZ3FN2(vPs9Hso^vz4KqJc*UIQvIxD!n<4jTY+ascQP_k(UUty$NVTvz)Wq z&{40zPwBPhvZBE=1Vvt60!d!1-2XMp9JXJp$;L-C*D zBx9p)ux(pz!afg6RHY;iZynJV2R9O@0}KcZjEpP>gEx(;nrZ2rju)lJqT{fl?6pJR zX_OPC>z!n1;ozPCu)3T+hw@}AH>9jte7Mu#$o&J*Awf1OuZ^tOOP;l(+Kb5i&%5m{BDO zcz61=?|xdwN*D{r_Y>}a@Lk&Sz&CpNcWD+|*hW9TnMG8|BM3iO;1;DeJZ3d}9WB?I))xkve(m5pi`dpA>!r_cem%d61Ej!`B`#2H9yi z+56{c^J!L=rR3Hy^~Xj)&IgkOoFpc~S2DwUsWsY)UOJ3Feptw}u?~X$T(_~0uibnN zVHIy9EDy@-`a|Il!+|rFe{+h!x-5Qx+Fr!-8b}Y6RE$#IArX(}bP$Mpa z|FvB2wUh@%sp-`E*Vl8GfS`2LND1cF%fAYKLl9rX>~6VkZ?nsB;o(8x3FKT(- zefNRP7$K{W%l#^YGf#w)o5UI{4pLEm;MhRDGB z*?iKTcWy143Xx0c?O>~+t`KSKq2u`+6e( zikfEhH(RS^8tO}6z#=kQ+gqDaWJ@9rNc25kv5>JbAnohk6LIU_;N|P?amsCAFlkqw#n@E& zwDfK@f1*>s8I_nckA0_+BBWlq2=!Xw9 z_B~XodTo%rDx;f$$6y029YsNEQOA>;yYS2M<;m#TTcCvw7jK`?wH^0t_5(MUie8&z zBM*R5aib~^30v0c_}`1z=uaB5&Za(PlUPcUy{!uBQnZGWqVhU)FVQ#ST^uo#H1*W5M(XDdFzJk@EOG1y(= zEDj=0o{gm5%NQg}qt$tCpn;|j1ePR^r|&R{2LB6&7J>AO{11k%v<7WL!o~&vH-=73 zUCe}F=&t)^K|w*zKUNY;xU*qDs}O%#cv}XZ!DF^`(-6nuCpShq#O~5w>xP}$1gBLI zFQn?n9y^q_axEq67EAyAiQOC__b-xz#nq0r96;umcol#{6Lmz{%x3519Ok?Jx|6=wOrM*|isO zu5OMLxs#?@@Lv4We^ZopiPH__h4RVm`iY0*H+p{Tf3+-NK+Y;FJS8eCOc`H+TwYM+ zLw(BMxzYiqmY*MHN8!?tRKW?wXs?~H-W$Dnb(yaBaI~4ZZYd^^(_nbkmQvGjHK4~y zp3H8YS?r0Lif7&QzZB#$kXq()H7GCaC6iQvk1amsb9}q#d)!ayAqeNNVj%!QeSUg8 ze`uXunU&OfpyRP$hmK~BQq|Hz5FrTn%tMz(84}T0gxb^93{BUnJGgN);Q5If-w6i~ zzJ88>7n;K4Ky@_`pKqQ+Mi3P#+*W45A@JKp`jfZuTPrJmuVE}5Gb$@1T<2b|_SAV! z#_X|8qpzU6yb(1%8ZdO%fB_9)v>2Zk#ZbnM0gaKiIZds6=kRu=j>o15-7Yrh(s6oI zI9u)=S`%Or;|F4UU{DR8Qc}Fg<*JIbe<)$^72g=TG>N}LZc;9g=Ij?W-EVIUfr)+yac355Z zP|+hsDB%5Jxw-yT;AZCSxGWk!)Nb<@kC?~4tjY&F;BhG8XJvHi%>4Y&XxVrdV6EKd6z9nqr%Wps=h11?O7peH`bSDc)F_oz*0(}sy$Cd4JeT=(uonJRCy*kl?bx zgoQe(o@C)Q92+FYPi!(!*Pz6j{k29`<$p(oF8mBE)2V)hxC5}FD-N>}%Cf%GL#<$W zffu%?qwnb{la}Z#ft8iony^DNXeVX{%M-7sKs2~9TCx}pA5CISu0e30z9f}OOcBH1 z*2!TPx|`wTh$vn7Rs#wYag-qnL$mAS-@*h=olHw1E*1MX$hBXDWwb|+b7Wq~zcR$1CgC7baUx|0F^m>U7-p@gnp zwsj^)oVha}x5}rnI|iJb!nT3VPS|vPcigqjEk$NLmbH)<|6wu+AwoYIQ?lDnleJlRLZuuW3r{1Q)b+H_0N;*;NB^vk8Ao8Rh^|8J*Dr< z@H}?DDPjWy?6=WOC}-$@$H2ZMV}(3q<+NXO4-YGvTj>b>#IS4Ca;nH-Lro%6L()5v z0b+>XxEH=Syy6fM{hOHMH_ejI{1TP6k7C)vaR@_U-pVer@tK!&z3JHBoB>k|63MKrOdMZ5&I&gVh+WF+M%>U|jxy)NJOq0GD7>D)|&B3BK3?$jMs$PnB62?On!H@&sxfxgxISa&iG zhlA`@0z1+88K*wcm2P4ZB6uvH`XWwc)HSXyd@jL<*?QXG?3P#m(|gs#ktpP6i#q`M z%ZuOoTv|z~*XN@=AGFXS23chs3m_D_9)OXy!Zn;RW%@8`jBF&D00$Yaq+}t8#mg6t zNmD>vK|qlJ9wK>!<@r5g-Vk>6L`WcvtG%x}cD*`8TIkV+?63VQ#(-b8_=mXwK5`-I2=D7k* z52baO(-0nDrB=rxSVRO2hagfvQY@EN#86=SLup(TBT=?Alh+Y#nXNs|jr3G!YjY&+ z8pErtvUZDX={67b!>C5$wMmE2y5eE~WC4wPhlsrz@+@e@asoR`{{)i=pq$x2H zt$wj9FA*Jw9F(K7XYDbN#OaVLm}@ERvkqcj7EV@ARWV-%Cv~VVy|Q^;zZWKjLDF5as7-@Z!?^+ds#j zCQpzJbI6-Ez>qAvjgvu587m{hMaZ%B1gbk$s*|W|&|*cYqX^|sb6VU;xZAJ&iLY^N zFk90*+icCml_RIlpQbLQf%Kl_G==Zy_$_5x6bST>bP0oz>#*U%SqGx|Mn6O(~1m4crv|C@TLyJfnF`Y>gYVQ{*Qg>U@$}oQ? z?pK4Oi0BGaJ!_?2PODTFVMu6b)((wzG0hnmJy}WXYQCv0`jf~^`~h>wlOjQu1>aH) zzYrc8 z`N8RZn2~16Th2f)Wy?Z5vxh=r%L*?Unj+@zCq;D;hG18|$&i>2vZrpbnquJW7r^Ox&1`8Y$|2JZqFw(*Jj z53^W%Tv=Dnpr+)ij1JoR{75G%YquEqQF-Dk5BBHj=7E)k)t-bGh+{xKt#P<`;Y6 zOYtf3hu9JDE(e&E7rO?6_pZ)qnXh4$k6aQ%(^&~v1yxf2n)BN!~^c$ijJM_`UKu8Zv<)4GK6%=&# zQYKb_wQA24JCV+&4RZ?hsmVF^d%%MnD`uYU*g4^FrCca6L>n8M$Psu)p6DC`4nje` z9PLrCk}gL;jyk$;$Y0fU2D4(kBTP2AU$hfqcdOP z9cusLZb#aCG6uU#3ETC}TQ~nJY?lc0{Un7@ZEes8jPENT0*s@;{l7SNfsp>d|8eYw zgiIy?0K(V*)3M9-%D_43xDQy=FxXh!;M{0J=R|3TO_IdGRZm>u0LA#r;5dl_F(0fJ z_K&~H{Q17%8?_eoH3KXOY5E6 z&ym;xk|(Q`mV=do@#% z29I7|l@NoyT%Hw&ippJ6-6TP~sgfjEcCY66OlgY^&Ud<-x?r>-gZ|k(oW}VmR4XL- zub1mCkMk)XqTpe>?iT5S*M>r+!mWlESdAoijwouAz0E>S?!D$N#9>Dsnim zf!<+vkQ~@aOtK(U!RZ>|Kna1lLp^&krB;Xt_Pd9n^JRt#dGRvDlfjV0)JX#Y&DYVa zRLrXNTf!j&n63~r{e3$~Z#Q7YksLU1Px_G?MuAvi#&_2J`p?e!1DZa0M|Dc`E zP&*Z0Yiu4wqFA1SZ{!>IsgE;Eg#B+hP!sw#Flgr5b=#Sd+3*G{J>Zfb_L=8h8w(>s zku)8W6ebQ%gkT<(v-v>cvM6f;zjvQ`u9dC_IialvOpUl(8Te{s5opqn$4{EfHN_5# zx=Xcw4~V2}EG#GBK*DfQnxevQ8eH7J|8kBtdd<^0`+oi9e2HfOCn^GB?7n)H)4%VL zL%c0SF*k>kUiR&#CkPKJvz90(Dpbx`UnjAtWa`)sCaSFZeD&3MXo;=e_ZcPF1lLhh zv#wW8LnGYpRIj^npYeX_p_eh018IhsH2wTuDE(jvDMCY?dw2z#PTA0(b#YN6mRKmX z0L?~Q+}3`CO98@`J-iNZ`NQ??t7K{_4yBAB(^Q#tz(8yMM!B$Z5qe6!x&Yf9Qw_k!6t6hF3i zGRwiu&Fyl@=Kp(PqECqe<9HF|Z-!M+70*xm=ma?2D?Ra+n>WR(rXSK0Q4#Rs~g7qI@-Bm@xD%TmIs-PA4Kj zy4A8ZePiLaP~#QT*|`X-!7a$=kY$yGm{nUGu70MN3AU|bU}e}&aQVm%4yGcBe}rcx zK$*v~+G&U&H$QBadu-%zoBx(f1Aox*G%=Xo{#NVrYhqkfB{dYuHjA#yZZ{qM*txH+;`Nx$F4-VUBT3Fq}Qrb`x0?;I} zuf=v+jb}Fb<`wl>a_UU~N$1r28>;?$h+HQ45hipHzEJrmr~9%kgn#kU4&ree>zD_#zUmz-B2MRSOvvJ&z#%oFYUwe8pP z^ZUkX*z~A^Nh2}d4%-4`=&D^odT)n($XEoxA$BBKy~Hzjytg~X(Ky0y>~<7uBMyR5 zr4yQ1i(LAwaBX@rHi+5TZ?BK7*%^KtD6j8%I{$~YCoB;vj8uh?;qh0lS&M+BSs8a>Dv0x zsy`>)*84n=Z3eWVC9KU;6H5C)59cP3(~>T`_DDQ?i;M1${osEt6-uh8e6^zPt=Y`{uNJdR z>mU8%z#N#qhq8u-s5Qr~sBFvW!L5)Lkv{y8-IK@KH-E#&F*(rD-kSl0y__b4(#ga0 zn4$}i9R10+_nSnivA@Z4BQj~^~%=KmhpEDqh;jMKY_fub0{e*YHJa;@!s=40(T zGkt%2*}|Gb5gXuv@n~;mL_?G^Il0lOHtb1j+w#X(k^RUEtUWR#fM;Rxmz9h5=~k*& z!iB8Uau*^0M^jlEEJ+;uUy2BMH1M@M#@O`vT&W||@1wo}WL2*VQVd8cUBHXtv)y2kg}5 zuS@%(mj??f0%Vevao{WhU;dR@7g^U>>8484h}qi4Mjgo)bRG(nEi8Sp)Mo5^og^Xi zdqslBMHNfiQ;Je=+2|L%p=c@#Xw!mscrViG*faWwV@@Yd7{h*O>a1;YG!k5%&KuwK zd(m#SqvaTqP+5JAKXauj^NLsV5oBC{#2=S(<><_9E19o< zUbcds&|Y-KGFFsL1?u_=_TyUq5mh7|3yUD9Q&8aVW~T4gC*SjTz)!pWOYA5`S>b@c z=gvO64=-O_=Tw$7G-!*gt!z>`%)UW}7qu9nnaX4j*G4HyBFL)6jIxrMW+7w5F8J9$ z5%9hnCa&~ErT{{w9cXa4oQceH+*K@S6^8e z{5Y#Wxeq}6rJVGcex%?)yEbZmlKoCQz`D~in@+DCE!tQ=ar;z7)sm2##!wEsIeQED zqPl#Zw1vjkJRs@#dmFe=@H-&6pH&>i@oK8HIdj`? z>c>o+kCqNQ_LYUy5`SxK5>VGlS5x80T+Nb|y%GFbgQ3Z-tEZRZPV*u*2g|^E^QB_9 zi;MTg=kC)nvjHY5OC=VX6al{jPB`7w)0EP+cMlT2LKYh7?mo7X`E?V-B6|5O26Y{- zw$d&H2gAZoaUr$BHpuYg8C=m%3HkvQ3JD-_YjdxS&uJ`rGD8C)?E(%s5inf_-(;J7 zE-UvqhRkpFn=tOzge> zll@#ZZ6>QgE0hq6_`3D+0GsgYuV@M*<%3W=K3$w3(BG%;Wu1VkZ=IK2j^Fn?A>i53 zyrc2TQy(N;b;=hP7Z(e3qahYv{cE{Sf`;?`nK#_GbkCkC29ta6uS)UHJXJ}J5ed3H zLA+wMF>FTyLWX2I5gijAuMnkTPi4?tOc6vo8b@AZkE$7Xk#R5nhy_+;B%;k)F(pZ7 zYep6Qb=VgAzItnmRHXvps7#u2Hqdr3llgHXMY?Fl45qx2X!%cPkdn}5P;!B<2g)B2 z)W;n@p%OcG?iLMog*=Hxz_Dk5bQWMFY-Jxep{Sbqn)PpM2(M1Z8E&)-4s=aHIF&^6 zV|?>azt;v;+ol)WCp;l38PG26f-yrVQ8c^b#h$+F`+ht8#Z_7~{-)z{f)N!Ut*g$Ew|JtlnXW6Vrihv0sDzNESAz@d z#E0A1Xl@s1-u58$lSV~dy}va<_;6tsJLARZ0}?P)Co$PZGvZ0Jz?2MT;lFS6p44k! zcd+vDUksepBi-^VI3VWOHaO9QLPqZPZNE12zX!kk?Y8(NWTe;^s~!>5brlTGRR0UG6R4Dyi2gg;mtEIA;7?uu78_~jT>L-C??m(sUtq`=^uJ7 z1WiD`z>weAUk6W>H9|0Gi$*LV&whI*2ku-q5cZQo`AeHw`FmVlUB0ZdHCRCE0qX8AR2L%BMM@I@CUUf(f>DzH zJ;u9FM>S(I<64a!h5CZ6vtwm?|q* z_igQB2*H#tgH#wywgFCFvC<7~*c+|)Uim}8AerOX+ zbQ0zlw)esMQDzM|e^~lT=siauE1P_Ye~g|MbxIs=a&@rj*Sz4UusU+q5IFYYL(Ov) zVrK2V1@V*BFr?k?>}E8T{l0#c;c;ru=-aNtIc(m@{VanO_7!vN+93co&VJucRf%XJ|&13UkR!*FnhWkzn)t3B!4?<{As2T;0tJFy_M$D?O-nPylv87!fE{JOTu>xY(q55o(0 z5~|g}6Vc#S*44H?gCVm%ALzQamkGFp*PGse%*skGZtg;QFJLlFt0U2!H+G(t(r#o? zAiV-e-+}k*Nn~;R__z#iIpIcRl2zqe3b02MI#Pm4o`63mSu|vZOYp+~?HvmdmO#*( ztBy4<`=)7P>y>_asu$wIKA5Ax2FC*La14za=`38d-Y)Via z(|H+iug%~oZh9tFWG6Tb#g4dVnBph)xO$tYlO-r`t}c-RZLYn3P4{yo>IH(lCom@l zE!{3QHip%{wX+?DoAe-X_~+Lv@XFupUF_+{{dW&KUXVP9ns^&L|^%=o8CNsrtSdI(8nP9f*n= z_j)nAwX2%LxTpUWJUXSf*fsa}@9+@1VC34KEK&Kdqx~#G-tFXrc63iGJG))pzTR|_ ze0qzV72eJ8A?pNwD+Pq?_7ANOMF$5Sn`mHDZ5K=zbJ?UXdBgQZ{{V$>-;risfP)Zy z4lvbmGMg=YdQIiN^dagX8t;w;V8)YAX21=(~LpaDbvbj%?tLll5q(kOt&yh}Y8{ zyqsPeOH5CI!s+|y&vTghdtH!_>0TYQVn6scWaw)aSdFqhoQc<SG=>@X;&F=VHBii&=D zx#yM`{_YOp(dkV$r;(JKNC87?kdi5uK#;OFbhub+zP~o$XXWQ4=}^kBmE#&eBcr-H zN8=0ffCvLDB)B~z8qq+pe6jNptzAv5^!KlQKCtEUNw>>Jsl`qM=>Cyo=NCMW0MxiR zf%m`S5YPr;DVG~hG|9)s_UI@bng64xZ|^<`0z_DI-31j#Fg<1N(tmG=01fq-iS9Zirpnqu(bxIwSHt&HrI<%6@KR`Lz^jf~=5x;i))&Wj z7a7|9*aoxq!{|?HY3~ph_G?S1{Ap%r_tVlE60P~;)y_H>Q%C5duVJ1X73CZ4NgSm( za?>?Vg1NNL<)%F(e(`Jy3)~ z#LXTq-9R~hE5AhI(DUgnCBWkJUI{Dh>{jMi`<|wlCVgU}Ymz3?j9V^VKq4u5pBx2- zjCNygqdZq7$6>4t4le#E6#CQ7CL}TC|m+7hR%Dr80Mp$DVp3kVjAGgBAKbfp+0A<$B$1{|JCfwr= zlu$CwN!h1z&yQ*15wD?(%5!Zt(nTT;4h}a!f8ibFkG;n?|6uEAY~(Oi*2shX!~4?{ zb0ax8jL_Cki7E4aw3DqsA?H5}F{F%pkG}i2#B_$9+*d`ktvGs!@6as+ciL# z>Hf`7N6;TLT9NSoXGyu&33Ovx?oYlTl+eX4*b7n~cW=?X{m}}qtD+*RiyuCRH_oN- zj~H#P`=Qu1tT{5I&JKlkMAEtn_M`?-V=vd|g9aZpV#+9FKTB-KLmUDY%v}Z=ph(lR z`(M zp#Ak6t~3U0H1f^$k37o_5{$V zxiT*RI*JTP?7!qrqY)1#0TO{L%WNcPg5ua!hlc$4k>T|3c*pl~;G6l^>opeUctI|f z!M`B0$P5|sO+xJ6zgyiCp^cs&S~!xp zn{|qCQSp#1n@h6t+iexU)6m>5^d-~uG_p9DMqq2bjCWl9I@ebV#}sRntF;Q|cn+G7 z5PoMkJ??^0f@?SOgppZVtepkUX$Oywq2JDJ6@cCmK_2I|RgmWAD`U$q3dk@|taeYjyP|(OV1etK? z8}t5ruU?=h5qboCe5zm<`zC=Smhk_bG=lt*4uAsuPtr)=`KKiS@CEDtN*aClB{^7Z zt6Ry<-B`S^S){GXFrBwEGh-joNM<1!!iI(lJ|RH7gAzmHra_|-K`cz~4uqNyQIPRW zPTV%E=hz6e?>CO7S!?WU{galN`w&92M}>brWuvwF^8EHX(8$G?cKAO1H2w9VgLa7h zn8j#;XSaSWK5biMJWq(A1cl#w-QOC~R<613?St{+VcQ6S=<=RY(#f zaXC=0PdlMn*Ow$hANZ;3TBLxr8t(7P7YnP898y?b<$R5zIs}9^zs=n#beVM{jEVxHGyYd zg*p^DyYm2^w-z>8sz$hWD=~y^Hom>3e8KIeA`Bnky<>sWX|*inq6(sjJ2AY9o(!L_ z*lrwX%U;b84xp4v>lxaGn^304%u(cb?+Pwr(5?}-T7B}C=_b&oEXjF_-(6>JqKg;o~5ta8sZJRi6(c?(u^NR?JN!!-a&{w2) z?-El1y}Ju-qL}NH)z-#y8!fA8Cq@t{7QXNhFv5hW;RlH%>fi70+44FJ|M(F$e=-b7 z=$%h0J~H;v3mCGw7V9+*ym(>3@ZVw=$ zsa%ot))g)-E#gek@V5%4rJn8@$owzoti9LTRW4|lD*rDkI7kYM%oT1?<=aNVK1Y zAEyQ~gI7+?&Q=%TkE4|8OrUNgjQcD@_#OO;40ZtVIw_-J0jd$DXmHQ97S`Gq^vDF) zn1?#?D*QFQqu%}46tG_KC_H6S`rNI?Q7Kslj{r%%-4tHP+&qKXXSy&SY1Dke{QSAQCOWdq$+mc_Y)hHGn|C+%MD};S?JD9p z&AN5#9>5+4;JH8A|s$jvz{U(t`@qSty&>l5G{<$$r*+EFP^8UA2NZ^UE^%Qs_D@ zMD2Uv_$m|OsFpwwgkzoun>$aLAHeC46UhKxmS&(n_6He_XquLIN_CE)x#Ij%sfB%H zXSeC@vWzAzpufre?i*MvNBpIj;ew65eZg6aijCu;2qi6wNl-1M)kjXg;}HtYvw3xT zO7*97c+hmE7^a8Q{fqpi(i>tdSSf&MBjilQPMsO{5M1wfo9K|Tv60JdrM0(r+^dc_ z`|p4uUG3E`q(EroBKKgKaFqrBkq}e`oz@*Ht-pTwOJ5HcU|^-0MXwG2yPk6=)81d` zd7n>G=9#0}M~L@Ybn5Q>zZ&=n3zWsKoGKqp$ZKl$Q@b&!hzHPo|L$*&916O2L(7lf zpj`GBFPc-_n~FH6C5U~AgQldC$tc%->SgNHK?FeogOXQJ==9l@X>$3)X`dCj*d^#yvir$T_K}pmmb;bbvHhBn%ARAFE!U=` zOI`;@$Gs=tbBm+uWGLcWk#V3ba0X=H41zd4DdhZ0za7nydB=snqo1eCm2t^)TR(MI z>oJPv*h_6;6e~%@|2v*fOii`&5dgr=va#Z^`(GtSe~MQD6q-&5K7e@i(AJEt_{3Or znN=rcpDKf29yTktOvsuVak?3M2iMd{Ew4OUXTVwO@&}1`n;UPptQsHVI=*P6S=nAC zn3CJCHb&93BU)9)35byr&~T(_Hr7Iam(ocka=+vsy)Z>rRp$*WEp^eLaU-7?hYNNz z?|pK0YqPLq4B#s)4PUA$Ow@O5|Cw3&#Q5F8R;}N0M2l_7NDxB1q8;7LD(w zISYtIQ2V8QVEm?7jZ#|r&To<1$bZ*GfEiApN(b_n4ouVD(Dzh4W{l}AfI@_ynE7WF zvlV52MMx5B@JlhgI<3YWNDsvm?cx5S`D1=w>#X?--@s>m$X;<9yF0{>j3%HpZf4?% zamt!+Ymcsn`%h(=RnI~$Ay!zjEx=n0QNhg;?QXGi<2H{4fHaAoanF*6O8K5Oulzj2 zYlRsvT*e&o9voN;G8WGBeJ?5^jT803;hG3Q`iRuA;dGyaC?ET%t5wmmC6MDAfLB=9 zhiQq-!Qt=_u&4B}osxV3_hb6hA|?e=G%JhOZuVZ zM!cmgrq^;w&hN8#eC$`j$7`ZI#}x7k3Pj2!xILZcw?uRNUnhKGkK|;T{u$^bottf4 z#y4KqlqEX2u0qR7ho2CqBZ2FFIrj|9fF8!IN{+|x)oBE?D*R+Oe+ucFby%J~_j{-S zR884j_en9${@_c*40Y_5_FP-Q@HxjCD{Oj~#_cRtp=#-Gk^81iM*rSerm+8giKy%I zsXK~0Qg9G!wUaN6#mcWY_7}H;&pi$e8-V?bJO--nU$nIcw2l9MTF95H1(@8Y3*eEq z4>NqPUS$fMExyX}ITDw$Ww&AV@Iqhr-Z$>wW*lK{&TCMVbXs}!-4#dc)M@BAAnh5*;~!aD5`c* zN8Zoze}%uwn;a*}$68*N#E^LENfc$96)+F~`{a&`iW=p$2?(>VuL#^5-YRe_JUkrT zaI>L6s8jwic(lW5%(sFc}}kxpBTNH-Go5-V@{g96k886?DS4?+CgB_i#r_ z=S$O@O8p|3vIbI>@!R{%R1*^Gela9$8@x_0;eNzBdpuU-yeH}ck@Er)dW5!T(d_Ce z=i5&MIRI?)S@D+~6}ZbJZSN;(z%zof>1MkYD$`~2EeI`UkIQmvdYAQd#M;hof7#f$ zE6gfa#+T4uvLCZkGSdxRB)%Q%?%Qj>$uCSQB?U#LNpi{k1iZ9_FDj{??q|zZ-H#o1 zb~ERKi)#u?er&9}atIoDzxYl?r@?RE{cP_GSn|q+vAwaeEZ1dSdmDTDm17x!-v*1* z!&By;cs-6Ro}S_x?sgz^EXuya=g9o8e^t@OlVBUEMB|cQAaPIbt$Nf#4$AB+GfSh0 zN;3FzAn}>Zx?@t%Gs2tPI;v)ZA$oMse*(fOAW1Dbjcqth?&H;GdFK7pcMj*-k;kYt2JH z*L~9;+K2zJ9qR@~y?6A}HlCSZAODeRJPMRZKl`>c>2?Afv_4vP&L!SX*%K5O=eg`3 z@OD_C;bxxZHd!9jZR_{Da&rK$wX&lEGJCkcb&I%2tBSmDMV_$SZ9aB_gpuukQA^T6 zH+r9M^UAX&8(*uvZ;#_N4Q_stiZls%x(hgic*)S_xlXJ#xD2Z>o4qkAh!0=8ZpGh>%NhL)Q<)E#INZ<1=dqEr zL;|w)rwtpbEn99OGaj>SZ7q1mfSs2bh9k>%0DsthoEYoDtMM%n}KQ1z& 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni diff --git a/pyproject.toml b/pyproject.toml index 22b837e3..ea4fc619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,7 @@ unfixable = [] "benchmark/*" = ["E501"] [tool.pyright] -include = ["scenedetect", "tests"] +include = ["scenedetect", "tests", "scripts", "packaging"] exclude = [ # Pyright built-in defaults "**/node_modules", @@ -141,6 +141,8 @@ exclude = [ "scenedetect/_thirdparty", # Release tests pull in extra deps (opentimelineio, psutil) that aren't in [dev] "tests/release", + # User-local scripts (gitignored) + "scripts/local", ] # Modes: "off" | "basic" | "standard" | "strict". The 0.7 codebase is clean diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py index 083a0a88..3a04f012 100644 --- a/scripts/generate_assets.py +++ b/scripts/generate_assets.py @@ -9,8 +9,22 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Generate pyscenedetect.ico and logo PNGs from SVG sources. Requires Inkscape and Pillow.""" +"""Generate pyscenedetect.ico, logo PNGs, and Windows installer branding from SVG sources. +Outputs: + - icons: packaging/windows/pyscenedetect.ico, docs/_static/favicon.ico, + website/pages/img/favicon.ico + - logos: docs/_static/, website/pages/img/ + - installer: psd_square_small.ico, installer_banner.{svg,png}, installer_logo.{svg,png} and + scale variants for .msi creation + +Usage: + python scripts/generate_assets.py + +Requires Inkscape and Pillow. +""" + +import argparse import contextlib import shutil import subprocess @@ -19,7 +33,7 @@ from pathlib import Path from typing import NamedTuple -from PIL import Image, ImageFilter +from PIL import Image, ImageDraw, ImageFilter class LogoOutput(NamedTuple): @@ -53,6 +67,29 @@ class LogoOutput(NamedTuple): LOGO_SVG = LOGO_DIR / "pyscenedetect-logo.svg" LOGO_BG_SVG = LOGO_DIR / "pyscenedetect-logo-bg.svg" +SLATE_SVG = LOGO_DIR / "pyscenedetect.svg" # slate-only icon (256x256) + +INSTALLER_DIR = PACKAGING_DIR / "windows" / "installer" +GENERATED_IMAGES_DIR = INSTALLER_DIR / "Generated Images" +ARP_ICO_PATH = INSTALLER_DIR / "psd_square_small.ico" + +# Classic AdvancedInstaller theme: brand mark on a colored panel. +# Banner is full-bleed light blue (BG) with the FG-bodied slate on the right; +# dialog is white with a dark (FG) strip on the left holding the inverted +# (BG-bodied) slate. +BANNER_BASE = (493, 58) +DIALOG_BASE = (493, 312) +DIALOG_STRIP_FRAC = 1.0 / 3.0 # left strip width as fraction of dialog width +BANNER_ICON_FRAC = 0.75 # icon side as fraction of banner height +DIALOG_ICON_FRAC = 0.55 # icon side as fraction of dialog strip width +SCALES: list[tuple[float, str]] = [ + (1.00, ""), + (1.25, ".scale-125"), + (1.50, ".scale-150"), + (2.00, ".scale-200"), +] +TOP_LEVEL_BANNER_PNG_SIZE = (1634, 211) +TOP_LEVEL_DIALOG_PNG_SIZE = (647, 407) # Heights match the natural SVG aspect ratio (1024x480). # _small outputs use the -bg variant (background included). @@ -88,6 +125,7 @@ def make_icon_16() -> Image.Image: """Create a hand-crafted 16x16 clapperboard icon.""" img = Image.new("RGBA", (16, 16), FG) px = img.load() + assert px is not None # Clear 1px padding on all sides for i in range(16): @@ -156,6 +194,138 @@ def render_logos(inkscape: str): print(f" Done ({len(LOGO_OUTPUTS)} files).") +def _render_slate(inkscape: str, work_dir: Path, side: int, *, inverted: bool) -> Image.Image: + """Render the slate icon at exact size with Inkscape. + + With inverted=False, the slate renders with its native FG body / BG stripes + (right for placing on the white banner). With inverted=True, the SVG color + codes are swapped before rendering so the body becomes BG and the stripes + FG — needed for the dialog's dark FG strip, where a non-inverted slate + would blend into the background. + """ + if inverted: + sentinel = "__SWAP_FG__" + svg_text = SLATE_SVG.read_text(encoding="utf-8") + svg_text = ( + svg_text.replace("#2a3545", sentinel) + .replace("#e0e8f0", "#2a3545") + .replace(sentinel, "#e0e8f0") + ) + svg_path = work_dir / f"slate_inv_{side}.svg" + svg_path.write_text(svg_text, encoding="utf-8") + else: + svg_path = SLATE_SVG + out = work_dir / f"slate_{'inv_' if inverted else ''}{side}.png" + render_svg(inkscape, svg_path, out, side, side) + return Image.open(out).convert("RGBA") + + +def _save_baseline_jpeg(img: Image.Image, path: Path) -> None: + """Save as baseline (non-progressive) sRGB JPEG. Required by Windows Installer's + dialog renderer; progressive JPEGs decode as solid black at install time.""" + img.convert("RGB").save( + path, "JPEG", quality=92, optimize=True, progressive=False, subsampling=0 + ) + + +def _compose_banner(slate_fg: Image.Image, size: tuple[int, int]) -> Image.Image: + """Banner = full-bleed BG (light blue) canvas with the FG slate on the right.""" + width, height = size + canvas = Image.new("RGBA", size, BG) + pad = max(2, round(height * 0.10)) + icon_x = width - slate_fg.width - pad + icon_y = (height - slate_fg.height) // 2 + canvas.paste(slate_fg, (icon_x, icon_y), slate_fg) + return canvas + + +def _compose_dialog(slate_bg: Image.Image, size: tuple[int, int]) -> Image.Image: + """Dialog = white canvas with FG strip on the left holding a BG-tinted slate.""" + width, height = size + strip_w = round(width * DIALOG_STRIP_FRAC) + canvas = Image.new("RGBA", size, (255, 255, 255, 255)) + draw = ImageDraw.Draw(canvas) + draw.rectangle([(0, 0), (strip_w, height)], fill=FG) + icon_x = (strip_w - slate_bg.width) // 2 + icon_y = round(height * 0.20) + canvas.paste(slate_bg, (icon_x, icon_y), slate_bg) + return canvas + + +def render_installer_jpegs(inkscape: str, work_dir: Path) -> None: + """Render the per-scale baseline JPEGs that ship inside the MSI. + + Outputs `Generated Images/installer_{banner,logo}{,.scale-125,.scale-150,.scale-200}.jpg` + from the master SVG. These are gitignored — pre_release.py --release rebuilds + them before each MSI build, so they always match the current logo without + being re-committed every time. + """ + GENERATED_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + # Render the slate at the exact target size each iteration — sharper than + # rendering once big and downsampling, and avoids Pillow's resize stub mismatch. + for scale, suffix in SCALES: + bw, bh = round(BANNER_BASE[0] * scale), round(BANNER_BASE[1] * scale) + dw, dh = round(DIALOG_BASE[0] * scale), round(DIALOG_BASE[1] * scale) + + # Banner icon sized off height (the limiting dim — banner is wide & short). + # Strip is wider than the icon, so the icon centers within it. + banner_icon_side = round(bh * BANNER_ICON_FRAC) + slate_fg = _render_slate(inkscape, work_dir, banner_icon_side, inverted=False) + + dialog_strip_w = round(dw * DIALOG_STRIP_FRAC) + dialog_icon_side = round(dialog_strip_w * DIALOG_ICON_FRAC) + slate_bg = _render_slate(inkscape, work_dir, dialog_icon_side, inverted=True) + + banner_path = GENERATED_IMAGES_DIR / f"installer_banner{suffix}.jpg" + dialog_path = GENERATED_IMAGES_DIR / f"installer_logo{suffix}.jpg" + print(f" {banner_path.relative_to(REPO_DIR)} ({bw}x{bh})") + _save_baseline_jpeg(_compose_banner(slate_fg, (bw, bh)), banner_path) + print(f" {dialog_path.relative_to(REPO_DIR)} ({dw}x{dh})") + _save_baseline_jpeg(_compose_dialog(slate_bg, (dw, dh)), dialog_path) + + +def render_installer_static(inkscape: str, work_dir: Path) -> None: + """Render the stable, committed installer assets — only re-run when the logo changes. + + Outputs: + - psd_square_small.ico (copy of pyscenedetect.ico) + - installer_banner.png, installer_logo.png (top-level audit masters) + - installer_banner.svg, installer_logo.svg (top-level + Generated Images/, master SVG copies) + """ + GENERATED_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + + top_banner = INSTALLER_DIR / "installer_banner.png" + top_dialog = INSTALLER_DIR / "installer_logo.png" + tbw, tbh = TOP_LEVEL_BANNER_PNG_SIZE + tdw, tdh = TOP_LEVEL_DIALOG_PNG_SIZE + top_slate_fg = _render_slate(inkscape, work_dir, round(tbh * BANNER_ICON_FRAC), inverted=False) + top_dialog_strip = round(tdw * DIALOG_STRIP_FRAC) + top_slate_bg = _render_slate( + inkscape, work_dir, round(top_dialog_strip * DIALOG_ICON_FRAC), inverted=True + ) + print(f" {top_banner.relative_to(REPO_DIR)} ({tbw}x{tbh})") + _compose_banner(top_slate_fg, TOP_LEVEL_BANNER_PNG_SIZE).save(top_banner, "PNG") + print(f" {top_dialog.relative_to(REPO_DIR)} ({tdw}x{tdh})") + _compose_dialog(top_slate_bg, TOP_LEVEL_DIALOG_PNG_SIZE).save(top_dialog, "PNG") + + # SVG references: drop a copy of the master logo+bg SVG at every spot the + # repo previously kept a reference rendering. These aren't read at MSI build + # time (the JPGs are what ship); they exist as audit artifacts. + for dest in ( + INSTALLER_DIR / "installer_banner.svg", + INSTALLER_DIR / "installer_logo.svg", + GENERATED_IMAGES_DIR / "installer_banner.svg", + GENERATED_IMAGES_DIR / "installer_logo.svg", + ): + shutil.copy2(LOGO_BG_SVG, dest) + print(f" {dest.relative_to(REPO_DIR)} <- {LOGO_BG_SVG.name}") + + # ARP product icon: reuse pyscenedetect.ico under the filename the .aip + # references (line 17: ARPPRODUCTICON psd_square_small). + shutil.copy2(ICO_PATH, ARP_ICO_PATH) + print(f" {ARP_ICO_PATH.relative_to(REPO_DIR)} <- {ICO_PATH.name}") + + def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: """Render the SVG at all icon sizes, applying sharpening where configured.""" images = [] @@ -183,7 +353,24 @@ def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: def main(): - persist_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else None + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "persist_dir", + nargs="?", + type=Path, + help="Optional directory to persist intermediate PNGs (default: tempdir).", + ) + parser.add_argument( + "--installer-jpegs", + action="store_true", + help=( + "Only regenerate the per-build installer JPGs (Generated Images/*.jpg). " + "Used by pre_release.py --release before the MSI build." + ), + ) + args = parser.parse_args() + + persist_dir = args.persist_dir if persist_dir: persist_dir.mkdir(parents=True, exist_ok=True) print(f"Persisting PNGs to: {persist_dir}") @@ -194,15 +381,24 @@ def main(): ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() with ctx as work: + if args.installer_jpegs: + print("Rendering installer JPGs...") + render_installer_jpegs(inkscape, Path(work)) + return + images = render_all_sizes(inkscape, Path(work)) images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) - print(f"Output ICO: {ICO_PATH}") - print("Copying favicons...") - for dest in FAVICON_OUTPUTS: - shutil.copy2(ICO_PATH, dest) - print(f" {dest.relative_to(REPO_DIR)}") - render_logos(inkscape) + print(f"Output ICO: {ICO_PATH}") + print("Copying favicons...") + for dest in FAVICON_OUTPUTS: + shutil.copy2(ICO_PATH, dest) + print(f" {dest.relative_to(REPO_DIR)}") + render_logos(inkscape) + print("Rendering installer branding (static assets)...") + render_installer_static(inkscape, Path(work)) + print("Rendering installer JPGs...") + render_installer_jpegs(inkscape, Path(work)) if __name__ == "__main__": diff --git a/scripts/pre_release.py b/scripts/pre_release.py index a38a385b..67f58b98 100644 --- a/scripts/pre_release.py +++ b/scripts/pre_release.py @@ -17,7 +17,9 @@ pyinstaller packaging/windows/scenedetect.spec ``` """ + import sys +import tempfile from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent @@ -25,6 +27,7 @@ sys.path.insert(0, str(REPO_DIR)) sys.path.insert(0, str(SCRIPTS_DIR)) +from generate_assets import find_inkscape, render_installer_jpegs # noqa: E402 from update_installer import msi_version # noqa: E402 import scenedetect # noqa: E402 @@ -42,7 +45,7 @@ installer_aip = INSTALLER_AIP.read_text() # The .aip stores the numeric MSI form (e.g. "0.7.0"), not the Python __version__ # (which may be "0.7-dev0", "0.7", "0.7.1", ...). Normalize through the same - # function update_installer.py uses to write the .aip so the comparison is apples-to-apples. + # function update_installer.py uses to write the .aip so the comparison is correct. expected = msi_version(VERSION) aip_row = f'' assert aip_row in installer_aip, ( @@ -50,6 +53,12 @@ f"Run `python scripts/update_installer.py` to refresh the .aip." ) + # Refresh installer JPGs from the master SVG. + print("Regenerating installer JPGs...") + inkscape = find_inkscape() + with tempfile.TemporaryDirectory() as work: + render_installer_jpegs(inkscape, Path(work)) + with VERSION_INFO.open("wb") as f: v = VERSION.split(".") assert 2 <= len(v) <= 4, f"Unrecognized version format: {VERSION}" diff --git a/website/pages/img/favicon.ico b/website/pages/img/favicon.ico index 019c86150820b2975616d560c6cd01bc7cdd0aeb..bf8cbf10a2938375fc07a27300dd44f57df47bba 100644 GIT binary patch literal 28910 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%0DsthoEYoDtMM%n}KQ1z& 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni From b959971178adcf78f6d72545f9ce73ed1f0974ff Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 2 May 2026 16:25:22 -0400 Subject: [PATCH 092/163] [build] Add docs/LATEST_VERSION to avoid needing to make actions updates when new releases happen --- .github/workflows/generate-docs.yml | 8 +++++--- docs/LATEST_VERSION | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/LATEST_VERSION diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 10cefe3f..d5d608a9 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -14,14 +14,16 @@ jobs: update_docs: runs-on: ubuntu-latest env: - # TODO: Figure out a better way to handle figuring out what version /latest should be, - # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.7' scenedetect_docs_dest: '' steps: - uses: actions/checkout@v5 + - name: Get Latest Version + run: | + git fetch origin main --depth=1 + echo "scenedetect_docs_latest=$(git show origin/main:docs/LATEST_VERSION | tr -d '[:space:]')" >> "$GITHUB_ENV" + - name: Set up Python 3.12 uses: actions/setup-python@v6 with: diff --git a/docs/LATEST_VERSION b/docs/LATEST_VERSION new file mode 100644 index 00000000..2228cad4 --- /dev/null +++ b/docs/LATEST_VERSION @@ -0,0 +1 @@ +0.6.7 From 4ece0acf886c77d09059c6b49612cb5975df3879 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 2 May 2026 16:25:32 -0400 Subject: [PATCH 093/163] [docs] Update release plan --- RELEASE-PLAN.md | 86 +++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index 2e968bc8..693a0500 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -1,79 +1,65 @@ # PySceneDetect Release Checklist -Use one copy per release (e.g. tick the boxes in a tracking issue or draft PR). -Version referenced below as `X.Y[.Z]` - replace with the real version throughout. +Use one copy per release, copy into a pull request and check each box as steps are completed. +Optional: version referenced below as `X.Y[.Z]` - replace with the real version throughout. -## 0. Branch setup +## 1. Version Identifiers, Branch Prep -- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. -- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). +- [ ] Create / fast-forward release branch: `releases/X.Y` off `main` if major/minor release. If patch release, fast-forward current `releases/X.Y` branch. +- [ ] Bump `__version__` in `scenedetect/__init__.py` +- [ ] Bump `docs/LATEST_VERSION` if needed (stable major/minor releases only) +- [ ] Regular release: No `-dev` suffix or other, pre-release: has suffix `-dev0`, `-dev1`, ... -## 1. Code & version - -- [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Regular release: No `-dev` suffix or other, pre-release: has `-dev0`, `-dev1`, ... - -## 2. Docs +## 2. Documentation, Website, Changelog - [ ] Docstrings / API docs reflect any signature changes (`cd docs/ && make html` builds clean). - [ ] `docs/api/migration_guide.rst` updated if any public API changed. -- [ ] Docstring examples still run (nothing references removed symbols). - -## 3. Website & changelog +- [ ] Docstring examples all run correctly, nothing references removed or deprecated symbols. +- [ ] Changelog has release notes for major/minor release, all features, breaking changes, bug fixes, and known issues are documented. +- [ ] `website/pages/download.md` updated with the new version / installer link / release date. +- [ ] `website/pages/changelog.md`: move the release changes from the **Development** section at the bottom to the top. +- [ ] `website/pages/index.md`: Latest release version and date updated. -- [ ] `website/pages/changelog.md`: rename the bottom **Development** section to `X.Y (YYYY-MM-DD)` and add a fresh empty **Development** section below it for post-release work. -- [ ] Changelog entry covers: new features, breaking changes, bug fixes, known issues. -- [ ] `website/pages/download.md` updated with the new version / installer link. -- [ ] Any other version-stamped pages updated (`supporting.md`, `cli.md` if commands changed). - -## 4. Tests +## 3. Tests +- [ ] Static analysis passing (ruff + pyright). - [ ] Unit tests green locally and in CI: `pytest -vv` (should collect `-m 'not release'` by default). -- [ ] `ruff check scenedetect/ tests/` and `ruff format --check scenedetect/ tests/` pass. -- [ ] Release test suite green: tag a disposable `vX.Y.Z-release-rc` or use `workflow_dispatch` on `.github/workflows/release-test.yml` - all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the 3-OS x 2-Python matrix. See `RELEASE-TEST-PLAN.md` for what the suite covers. -- [ ] `resources` branch has the artifacts the release tests need (goldens under `tests/resources/goldens/`, `tests/resources/stress_15min.mp4`). Re-push if any golden was regenerated. -- [ ] Manual smoke: fresh venv, `pip install .` (pulls opencv-python automatically) then `pip install .[pyav]`; run `scenedetect -i