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 001/130] 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 002/130] 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 003/130] 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 004/130] 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 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 005/130] 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 fda8c580834fa6de8efe2d0ce270e9fceef2016f Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 17:19:02 -0400 Subject: [PATCH 006/130] 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 007/130] [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 008/130] [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 009/130] [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 010/130] 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 011/130] [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 012/130] [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 013/130] [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 014/130] [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 015/130] [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 016/130] [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 017/130] [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 018/130] [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 019/130] [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 020/130] [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 021/130] [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 022/130] [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 023/130] [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 024/130] [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 025/130] [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 026/130] [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 027/130] [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 028/130] [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 029/130] [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 030/130] [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 031/130] [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 032/130] [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 033/130] [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