diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a6f82cd7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,71 @@ +# +# 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 +.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 + diff --git a/.github/ISSUE_TEMPLATE/scenedetect_package.md b/.github/ISSUE_TEMPLATE/scenedetect_package.md index 7fbca92c..3215afc0 100644 --- a/.github/ISSUE_TEMPLATE/scenedetect_package.md +++ b/.github/ISSUE_TEMPLATE/scenedetect_package.md @@ -14,8 +14,9 @@ Include code samples that demonstrate the issue: ```python from scenedetect import detect, ContentDetector, split_video_ffmpeg -scene_list = detect('my_video.mp4', ContentDetector()) -split_video_ffmpeg('my_video.mp4', scene_list) + +scene_list = detect("my_video.mp4", ContentDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) ``` **Environment:** diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml index 2bfb4f7e..76cee703 100644 --- a/.github/actions/setup-ffmpeg/action.yml +++ b/.github/actions/setup-ffmpeg/action.yml @@ -1,41 +1,112 @@ 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: 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: 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: 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" - - # 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 + - 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: Install ffmpeg (Linux) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Linux' }} + shell: bash + run: | + 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 + 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 $attempts attempts" >&2 + exit 1 + + - name: Install ffmpeg (macOS) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'macOS' }} + shell: bash + run: | + attempts=4 + for attempt in $(seq 1 $attempts); do + echo "brew attempt $attempt" + if brew install ffmpeg; then + exit 0 + fi + 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 $attempts attempts" >&2 + exit 1 + + - name: Cache ffmpeg (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + id: cache-ffmpeg-windows + uses: actions/cache@v4 with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "6.1.0" + 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: | + $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 + } + } + 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 + run: ffmpeg -version | head -n 1 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2f788f99..3c3c76ff 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -7,19 +7,25 @@ on: - cron: '0 0 * * *' pull_request: paths: - - dist/** + - packaging/** + - scripts/** - scenedetect/** - tests/** + - pyproject.toml + - .github/workflows/build-windows.yml push: paths: - - dist/** + - packaging/** + - scripts/** - scenedetect/** - tests/** + - pyproject.toml + - .github/workflows/build-windows.yml branches: - main - 'releases/**' tags: - - v*-release + - 'v*' workflow_dispatch: jobs: @@ -30,14 +36,13 @@ jobs: python-version: ["3.13"] env: - ffmpeg-version: "7.1" - IMAGEIO_FFMPEG_EXE: "" + ffmpeg-version: "8.1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -45,8 +50,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools - pip install -r docs/requirements.txt - pip install --upgrade -r dist/requirements_windows.txt --no-binary imageio-ffmpeg + pip install .[docs] + pip install --upgrade -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg - name: Download Resources run: | @@ -64,13 +69,17 @@ jobs: shell: bash run: | 7z e ffmpeg-${{ env.ffmpeg-version }}-full_build.7z ffmpeg.exe -r - echo "IMAGEIO_FFMPEG_EXE=`realpath ffmpeg.exe`" >> "$GITHUB_ENV" + export PATH="$(pwd):$PATH" + # moviepy.config resolves ffmpeg via imageio_ffmpeg at import time; `--no-binary` + # strips the bundled binary, so point at the GyanD ffmpeg we just extracted + # for both pytest and the subsequent pyinstaller step. + echo "IMAGEIO_FFMPEG_EXE=$(realpath ffmpeg.exe)" >> "$GITHUB_ENV" python -m pytest -vv - name: Build PySceneDetect run: | - python dist/pre_release.py - pyinstaller dist/scenedetect.spec + python scripts/pre_release.py + pyinstaller packaging/windows/scenedetect.spec - name: Build Documentation run: | @@ -81,9 +90,8 @@ jobs: run: | Move-Item -Path LICENSE -Destination dist/scenedetect/ New-Item -Path dist/scenedetect/ -Name thirdparty -ItemType Directory - Move-Item -Path dist/windows/README* -Destination dist/scenedetect/ - Move-Item -Path dist/windows/LICENSE* -Destination dist/scenedetect/thirdparty/ - Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ + Copy-Item -Path packaging/windows/LICENSE-PYTHON -Destination dist/scenedetect/thirdparty/ + Copy-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ 7z e -odist/ffmpeg ffmpeg-${{ env.ffmpeg-version }}-full_build.7z LICENSE -r Move-Item -Path ffmpeg.exe -Destination dist/scenedetect/ffmpeg.exe Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/thirdparty/LICENSE-FFMPEG @@ -93,9 +101,9 @@ jobs: ./dist/scenedetect/scenedetect -i tests/resources/goldeneye.mp4 detect-content time -e 2s - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: - name: PySceneDetect-win64_portable + name: PySceneDetect-win64 path: dist/scenedetect include-hidden-files: true @@ -103,13 +111,14 @@ jobs: runs-on: windows-latest needs: build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: + repository: Breakthrough/PySceneDetect ref: resources - - uses: actions/download-artifact@v4.1.7 + - uses: actions/download-artifact@v7 with: - name: PySceneDetect-win64_portable + name: PySceneDetect-win64 path: build - name: Test diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 957dfce8..d6f0f6ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,25 +6,34 @@ on: - cron: '0 0 * * *' pull_request: paths: - - dist/** + - packaging/** + - scripts/** - scenedetect/** - tests/** + - pyproject.toml + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** push: paths: - - dist/** + - packaging/** + - scripts/** - scenedetect/** - tests/** + - pyproject.toml + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** branches: - main - 'releases/**' tags: - - v*-release + - 'v*' workflow_dispatch: jobs: build: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13"] @@ -33,18 +42,15 @@ jobs: scenedetect_version: "" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - 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 }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -52,55 +58,102 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools - pip install -r requirements_headless.txt --only-binary av,opencv-python-headless - - - name: Install MoviePy - # TODO: We can only run MoviePy tests on systems that have ffmpeg. - if: ${{ runner.arch == 'X64' }} - run: | - pip install moviepy + pip install -e .[dev] --only-binary av,opencv-python - name: Checkout test resources run: | 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/ + # Instrumented while chasing a windows-latest flake where python exits 1 after a + # fully green pytest run with no output. `-X dev` makes shutdown-time warnings and + # finalizer errors loud; echoing the exit code separates python's own return value + # from anything the shell wrapper does to a crash code. - name: Unit Tests + shell: bash run: | - python -m pytest -vv + set +e + python -X dev -m pytest -vv + code=$? + echo "pytest exit code: $code" + exit $code - name: Smoke Test (Module) run: | python -m scenedetect version python -m scenedetect -i tests/resources/testvideo.mp4 -b opencv time --end 2s python -m scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s - python -m pip uninstall -y scenedetect + python -m pip uninstall -y scenedetect-core - name: Build Package shell: bash run: | - python -m build + # Builds the scenedetect/scenedetect-headless packages. + python packaging/build_all.py echo "scenedetect_version=`python -c \"import scenedetect; print(scenedetect.__version__.replace('-', '.'))\"`" >> "$GITHUB_ENV" - name: Smoke Test Package (Source Dist) + shell: bash run: | - python -m pip install dist/scenedetect-${{ env.scenedetect_version }}.tar.gz + python -m venv .smoke-sdist + VENV_BIN=.smoke-sdist/bin + [ -d .smoke-sdist/Scripts ] && VENV_BIN=.smoke-sdist/Scripts + source "$VENV_BIN/activate" + pip install "dist/scenedetect-${{ env.scenedetect_version }}.tar.gz[pyav]" --only-binary av + python -c "import importlib.metadata as m; ds = sorted(d.metadata['Name'] for d in m.distributions() if d.metadata['Name'] in ('opencv-python', 'opencv-python-headless')); assert ds == ['opencv-python'], f'Expected only opencv-python, got: {ds}'" scenedetect version scenedetect -i tests/resources/testvideo.mp4 -b opencv time --end 2s scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s - python -m pip uninstall -y scenedetect - name: Smoke Test Package (Wheel) + shell: bash run: | - python -m pip install dist/scenedetect-${{ env.scenedetect_version }}-py3-none-any.whl + python -m venv .smoke-wheel + VENV_BIN=.smoke-wheel/bin + [ -d .smoke-wheel/Scripts ] && VENV_BIN=.smoke-wheel/Scripts + source "$VENV_BIN/activate" + pip install "dist/scenedetect-${{ env.scenedetect_version }}-py3-none-any.whl[pyav]" --only-binary av scenedetect version scenedetect -i tests/resources/testvideo.mp4 -b opencv time --end 2s scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s - python -m pip uninstall -y scenedetect + + - name: Smoke Test Package (Headless Wheel) + shell: bash + run: | + python -m venv .smoke-headless + VENV_BIN=.smoke-headless/bin + [ -d .smoke-headless/Scripts ] && VENV_BIN=.smoke-headless/Scripts + source "$VENV_BIN/activate" + pip install "dist/scenedetect_headless-${{ env.scenedetect_version }}-py3-none-any.whl[pyav]" --only-binary av + # Confirm the install pulled `opencv-python-headless`, not the GUI variant. + # If both are present, cv2 imports may silently come from either. + python -c "import importlib.metadata as m; ds = sorted(d.metadata['Name'] for d in m.distributions() if d.metadata['Name'] in ('opencv-python', 'opencv-python-headless')); assert ds == ['opencv-python-headless'], f'Expected only opencv-python-headless, got: {ds}'" + scenedetect version + scenedetect -i tests/resources/testvideo.mp4 -b opencv time --end 2s + scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s + + - name: Smoke Test Package (Upgrade From 0.7) + shell: bash + run: | + # Both packages ship the code (no metapackage layering) precisely so that + # in-place upgrades from older installs keep working - a code-carrying dist + # flipped to a code-free metapackage would break here, since uninstalling the old + # version deletes module files a dependency just wrote. This is also why the + # short-lived scenedetect-core (published in 0.7.1 only) was yanked rather than + # layered on. Keep this as a regression guard for that failure mode + # (https://scenedetect.com/issues/558). + python -m venv .smoke-upgrade + VENV_BIN=.smoke-upgrade/bin + [ -d .smoke-upgrade/Scripts ] && VENV_BIN=.smoke-upgrade/Scripts + source "$VENV_BIN/activate" + pip install "scenedetect==0.7" + pip install --upgrade --find-links dist/ "scenedetect==${{ env.scenedetect_version }}" + python -c "import scenedetect; print(scenedetect.__version__)" + scenedetect version - name: Upload Package if: ${{ matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: scenedetect-dist path: | diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index 41a20bfb..199c5ef3 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -1,6 +1,3 @@ -# Checks that the CLI docs are up-to-date. If this fails on your PR, there may be some changes -# to the command-line docs that were not updated. Run `python docs/generate_cli_docs.py` from -# the root PySceneDetect source folder and commit the changes to resolve the issue. name: Check Documentation on: @@ -10,15 +7,17 @@ on: paths: - docs/** - scenedetect/** + - website/** push: paths: - docs/** - scenedetect/** + - website/** branches: - main - 'releases/**' tags: - - v*-release + - 'v*' workflow_dispatch: jobs: @@ -26,10 +25,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' cache: 'pip' @@ -37,8 +36,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv - pip install -r docs/requirements.txt - pip install -r dist/requirements_windows.txt + pip install .[docs,website] + pip install -r packaging/windows/requirements.txt - name: Check CLI Documentation @@ -49,3 +48,13 @@ jobs: echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." exit 1 fi + + - name: Build Sphinx Reference (warnings as errors) + shell: bash + run: | + sphinx-build -W --keep-going -b html docs docs/_build/html + + - name: Build MkDocs Website (--strict) + shell: bash + run: | + mkdocs build --strict -f website/mkdocs.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8499d1aa..9dd65e17 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0d4a0136..046e9c88 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..813e84e2 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,73 @@ +# Build and publish PySceneDetect Docker image to GitHub Container Registry (GHCR). +name: Publish Docker Image + +# Publishing a release build is driven by release.yml, which dispatches this workflow after +# artifact verification passes. +on: + workflow_dispatch: + inputs: + tag_latest: + description: 'Also tag this build as `latest`' + type: boolean + default: false + push: + branches: [ "main" ] + +env: + REGISTRY: ghcr.io + # Image names must be lowercase; github.repository is Breakthrough/PySceneDetect. + IMAGE_NAME: breakthrough/pyscenedetect + +jobs: + build-and-push: + runs-on: ubuntu-latest + if: github.repository == 'Breakthrough/PySceneDetect' + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - 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 + type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' && inputs.tag_latest }} + + - 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 + diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 74b20deb..305ad9fd 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -13,17 +13,22 @@ on: jobs: update_docs: runs-on: ubuntu-latest + if: github.repository == 'Breakthrough/PySceneDetect' + permissions: + contents: write # pushes generated docs to the gh-pages branch env: - # TODO: Figure out a better way to handle figuring out what version /latest should be, - # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.7' scenedetect_docs_dest: '' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + + - name: Get Latest Version + run: | + git fetch origin main --depth=1 + echo "scenedetect_docs_latest=$(git show origin/main:docs/LATEST_VERSION | tr -d '[:space:]')" >> "$GITHUB_ENV" - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' cache: 'pip' @@ -46,8 +51,8 @@ jobs: - name: Setup Environment run: | python -m pip install --upgrade pip build wheel virtualenv - pip install -r docs/requirements.txt - pip install -r dist/requirements_windows.txt + pip install .[docs] + pip install -r packaging/windows/requirements.txt git config --global user.name github-actions git config --global user.email github-actions@github.com diff --git a/.github/workflows/generate-website.yml b/.github/workflows/generate-website.yml index 328ac2cc..99abceb0 100644 --- a/.github/workflows/generate-website.yml +++ b/.github/workflows/generate-website.yml @@ -12,12 +12,15 @@ on: jobs: update_site: runs-on: ubuntu-latest + if: github.repository == 'Breakthrough/PySceneDetect' + permissions: + contents: write # pushes generated site to the gh-pages branch steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' cache: 'pip' @@ -25,7 +28,7 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv - pip install -r website/requirements.txt + pip install .[website] - name: Generate Website run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fcc75863..de468367 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -11,109 +11,108 @@ on: required: true type: choice options: - - test - - release - default: 'test' + - testpypi + - pypi + default: 'testpypi' jobs: + # Production publishes require all three release workflows to be green for the tag. + # TestPyPI publishes are exploratory and skip this job; the Resolve step in `publish` + # still gates on a successful Python Distribution run so artifacts are guaranteed to exist. verify: name: Verify Build + if: github.event.inputs.environment == 'pypi' runs-on: ubuntu-latest + permissions: + actions: read steps: - - name: Check workflows - uses: actions/github-script@v6 - with: - script: | - const { owner, repo } = context.repo; - const tag = "${{ github.event.inputs.tag }}"; - const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; - let workflowConclusions = {}; - - console.log(`Checking for successful workflow runs for tag: ${tag}`); - - const { data: response } = await github.rest.actions.listWorkflowRunsForRepo({ - owner, - repo, - event: 'push', - }); - - const runsForTag = response.workflow_runs.filter(run => run.head_branch === tag); - - for (const run of runsForTag) { - if (requiredWorkflows.includes(run.name)) { - if (!workflowConclusions[run.name] || new Date(run.created_at) > new Date(workflowConclusions[run.name].created_at)) { - workflowConclusions[run.name] = { - conclusion: run.conclusion, - created_at: run.created_at, - html_url: run.html_url, - }; - } - } - } - - let allSuccess = true; - for (const workflowName of requiredWorkflows) { - if (!workflowConclusions[workflowName]) { - core.setFailed(`Workflow "${workflowName}" was not found for tag ${tag}.`); - allSuccess = false; - } else if (workflowConclusions[workflowName].conclusion !== 'success') { - core.setFailed(`Workflow "${workflowName}" did not succeed for tag ${tag}. Conclusion was "${workflowConclusions[workflowName].conclusion}". See: ${workflowConclusions[workflowName].html_url}`); - allSuccess = false; - } else { - console.log(`✅ Workflow "${workflowName}" succeeded for tag ${tag}.`); - } - } - - if (!allSuccess) { - throw new Error("One or more required build workflows did not succeed."); - } + - name: Check required workflows succeeded for ${{ github.event.inputs.tag }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + required=("Python Distribution" "Windows Distribution" "Release Test Suite") + failed=0 + for workflow in "${required[@]}"; do + conclusion=$(gh run list \ + --workflow "$workflow" \ + --branch "$TAG" \ + --event push \ + --limit 1 \ + --json conclusion \ + -q '.[0].conclusion // ""') + if [[ "$conclusion" != "success" ]]; then + echo "::error::Workflow '$workflow' did not succeed for tag $TAG (got: '${conclusion:-no run found}')" + failed=1 + else + echo "[OK] $workflow" + fi + done + [[ "$failed" -eq 0 ]] publish: - name: Building and Publishing to ${{ github.event.inputs.environment }} PyPI + name: Publish ${{ github.event.inputs.tag }} to ${{ github.event.inputs.environment }} runs-on: ubuntu-latest needs: verify + # Run when verify succeeded (pypi) or was skipped (testpypi). + if: | + always() && + (needs.verify.result == 'success' || needs.verify.result == 'skipped') environment: - name: ${{ github.event.inputs.environment == 'test' && 'test' || 'release' }} - url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} + name: ${{ github.event.inputs.environment }} + url: ${{ github.event.inputs.environment == 'testpypi' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing + id-token: write # mandatory for trusted publishing + actions: read # for cross-workflow artifact download steps: - - name: Checkout ${{ github.event.inputs.tag }} - uses: actions/checkout@v3 - with: - ref: ${{ github.event.inputs.tag }} - - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: "3.x" - - - name: Install Dependencies + - name: Resolve Python Distribution run for ${{ github.event.inputs.tag }} + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.event.inputs.tag }} run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build Package - run: | - python -m build - mkdir pkg - mv dist/*.tar.gz pkg/ - mv dist/*.whl pkg/ - - - name: Upload Package - uses: actions/upload-artifact@v4 + set -euo pipefail + run_id=$(gh run list \ + --workflow "Python Distribution" \ + --branch "$TAG" \ + --event push \ + --status success \ + --limit 1 \ + --json databaseId \ + -q '.[0].databaseId // ""') + if [[ -z "$run_id" ]]; then + echo "::error::No successful 'Python Distribution' run found for tag $TAG. Push the tag and wait for build.yml to finish before publishing." + exit 1 + fi + echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + echo "Using Python Distribution run $run_id" + + - name: Download distribution artifact + uses: actions/download-artifact@v7 with: name: scenedetect-dist - path: | - pkg/*.tar.gz - pkg/*.whl + path: pkg/ + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + run-id: ${{ steps.resolve.outputs.run-id }} + + - name: List artifact contents + # Expect 4 files: sdist + wheel for each of scenedetect and + # scenedetect-headless. Both projects publish from this one step; + # each needs a trusted publisher configured on PyPI/TestPyPI for this workflow. + run: ls -la pkg/ - name: Publish Package uses: pypa/gh-action-pypi-publish@release/v1 with: - repository-url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} + repository-url: ${{ github.event.inputs.environment == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} packages-dir: pkg/ print-hash: true + # Tolerate retries: skip existing packages if for example only some variants were uploaded. + skip-existing: true diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml new file mode 100644 index 00000000..4c6de70d --- /dev/null +++ b/.github/workflows/release-test.yml @@ -0,0 +1,117 @@ +name: Release Test Suite + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + cache: 'pip' + - name: Install dependencies + run: | + # setuptools is upgraded because the toolcache copy periodically lags + # security fixes (e.g. PYSEC-2026-3447) and would fail the audit below. + python -m pip install --upgrade pip setuptools + pip install build twine pip-audit + - name: Version consistency check + run: | + # Parse __version__ directly so we don't have to install scenedetect + # (importing it triggers a cv2-availability guard). + VERSION=$(python -c "import ast,pathlib; print(next(n.value.value for n in ast.parse(pathlib.Path('scenedetect/__init__.py').read_text()).body if isinstance(n, ast.Assign) and any(getattr(t,'id',None)=='__version__' for t in n.targets)))") + 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 + # Pre-release tags (e.g. 0.7-dev0) ship before the changelog is finalized, + # so only enforce the heading on stable releases. + if [[ "$TAG_VERSION" == *-dev* ]]; then + echo "Pre-release ($TAG_VERSION); skipping changelog heading check." + # Major/minor releases use a '## PySceneDetect X.Y' heading; patch + # releases nest under it as '### PySceneDetect X.Y.Z (date)'. + elif ! grep -Eq "^#{2,3} (PySceneDetect )?$TAG_VERSION( |$)" website/pages/changelog.md; then + echo "Changelog is missing a heading for $TAG_VERSION (e.g. '### PySceneDetect $TAG_VERSION (...)')" + exit 1 + fi + fi + - name: Build and Check + run: | + # Builds the scenedetect/scenedetect-headless packages. + python packaging/build_all.py + # Glob by extension: dist/ also holds tracked website assets (dist/logo/), + # which `twine check dist/*` would reject as an unknown distribution. + twine check dist/*.whl dist/*.tar.gz + - name: pip-audit + # CVE-2026-3219 in pip 26.0.1 has no fix version available upstream + # and pip ships pre-installed on the runner (not controlled by this + # project). Re-evaluate when pip publishes a fix. + run: pip-audit --ignore-vuln CVE-2026-3219 + + 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@v5 + - 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@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install ffmpeg + uses: ./.github/actions/setup-ffmpeg + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # The dev extra supplies the CLI deps (click/opencv/tqdm) plus av and moviepy; + # a bare `pip install .` is now scenedetect-core with numpy only. + pip install .[dev] + pip install opentimelineio pillow psutil pytest + - name: Run release tests + run: pytest -m release -vv --ignore=tests/release/test_long_video_stress.py + + long-stress: + needs: static + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - 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@v6 + with: + python-version: '3.10' + cache: 'pip' + - name: Install ffmpeg + uses: ./.github/actions/setup-ffmpeg + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # The dev extra supplies the CLI deps (click/opencv/tqdm) plus av. + pip install .[dev] + pip install psutil pytest + - name: Run long stress test + run: pytest -m release -k long_video -vv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..80f5a530 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,217 @@ +# Release orchestrator: verifies the artifacts attached to a DRAFT GitHub +# release actually work, then publishes stage by stage, verifying each stage +# before starting the next: +# +# MSI install/upgrade test against the draft's assets (test-installer.yml) +# -> TestPyPI publish -> pip smoke install from TestPyPI +# -> publish the GitHub release (draft -> public, marked latest) +# -> PyPI publish -> pip smoke install from PyPI +# -> Docker publish -> docker pull + smoke run from GHCR +# +# This workflow is dispatched manually once the GitHub release has been DRAFTED +# with its artifacts attached (signed MSI/zip, wheels, SHA256SUMS); nothing goes +# public until artifact verification passes. Each stage is driven through the +# existing workflows via `gh workflow run` (rather than workflow_call) so they +# keep working standalone and the PyPI trusted-publisher configuration (which is +# bound to publish-pypi.yml as the top-level workflow) is unaffected. + +name: Release Orchestrator + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to verify and publish (e.g. v0.7.1)' + required: true + verify-only: + description: 'Stop after verification (no PyPI/Docker publish)' + type: boolean + default: false + +permissions: + contents: write # read the draft release's assets and publish it (draft -> public) + actions: write # `gh workflow run` on the workflows this one orchestrates + +# The run-id lookup after each dispatch assumes this is the only orchestrator +# running; never allow two concurrent releases. +concurrency: + group: release-orchestrator + +jobs: + orchestrate: + name: ${{ inputs.verify-only && 'Verify' || 'Verify + Publish' }} ${{ inputs.tag }} + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + steps: + - name: Validate release + run: | + set -euo pipefail + state=$(gh release view "$TAG" --json isDraft,isPrerelease \ + -q 'if .isDraft then "draft" elif .isPrerelease then "prerelease" else "published" end') + if [[ "$state" == "draft" ]]; then + echo "Release $TAG is a draft; it will be published after artifact verification passes." + else + echo "Release $TAG is already $state; the GitHub release publish step will be a no-op (re-run mode)." + fi + # Display version used by the pip smoke test; mirrors the tag + # normalization in release-test.yml (both vX.Y[.Z] and the legacy + # vX.Y[.Z]-release tag styles are accepted). + VERSION="${TAG#v}" + VERSION="${VERSION%-release}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Write dispatch helper + # `gh workflow run` returns no run id, so the helper polls for the + # newest workflow_dispatch run created at/after dispatch time (the + # concurrency group above guarantees it is ours), then watches it to + # completion, propagating failure. + run: | + cat > "$RUNNER_TEMP/dispatch.sh" <<'EOF' + dispatch_and_watch() { + local workflow="$1" ref="$2" + shift 2 + local start_epoch + start_epoch=$(date -u +%s) + echo "::group::Dispatch $workflow (ref $ref) $*" + gh workflow run "$workflow" --ref "$ref" "$@" + local run_id="" + for _ in $(seq 1 24); do + sleep 5 + run_id=$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 5 \ + --json databaseId,createdAt \ + -q "[.[] | select((.createdAt | fromdateiso8601) >= $((start_epoch - 5)))] | first | .databaseId // \"\"") + [[ -n "$run_id" ]] && break + done + if [[ -z "$run_id" ]]; then + echo "::error::Dispatched $workflow but its run never appeared." + return 1 + fi + echo "Watching run $run_id: https://github.com/$GH_REPO/actions/runs/$run_id" + echo "::endgroup::" + gh run watch "$run_id" --exit-status --interval 30 + } + EOF + + - name: Write pip smoke-install helper + # Same smoke test for TestPyPI and production PyPI: fresh venv, install + # the exact release version, and check `scenedetect version` reports it. + # Both indexes can lag a fresh upload, so installs are retried briefly. + run: | + cat > "$RUNNER_TEMP/smoke.sh" <<'EOF' + pip_smoke_install() { + local venv="$1" + shift + python3 -m venv "$venv" + local ok=0 + for attempt in 1 2 3 4 5; do + if "$venv/bin/pip" install --quiet "$@" "scenedetect==$VERSION"; then + ok=1 + break + fi + echo "pip install attempt $attempt failed; retrying in 30s..." + sleep 30 + done + [[ "$ok" -eq 1 ]] + local out + out=$("$venv/bin/scenedetect" version) + echo "$out" + grep -F "$VERSION" <<<"$out" + } + EOF + + - name: 'Stage 1 - Verify: Windows installer (install + upgrade on clean runner)' + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + dispatch_and_watch test-installer.yml "$GITHUB_REF_NAME" -f "tag=$TAG" + + - name: 'Stage 2 - Publish: TestPyPI' + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + # Dispatched on the release tag: the pypi environment's deployment + # branch policy only permits release refs (v* tags / releases/* + # branches), and the tag is the immutable ref being released anyway. + dispatch_and_watch publish-pypi.yml "$TAG" -f "tag=$TAG" -f "environment=testpypi" + + - name: 'Stage 2 - Verify: pip install from TestPyPI' + run: | + set -euo pipefail + source "$RUNNER_TEMP/smoke.sh" + # Dependencies are not mirrored on TestPyPI, so resolve them from the + # production index. + pip_smoke_install smoke-testpypi \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ + + - name: 'Stage 3 - Publish: GitHub release (draft -> public)' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + is_draft=$(gh release view "$TAG" --json isDraft -q .isDraft) + if [[ "$is_draft" == "true" ]]; then + gh release edit "$TAG" --draft=false --latest + echo "Published release $TAG (marked as latest)." + else + echo "Release $TAG is already published; skipping." + fi + + - name: 'Stage 4 - Publish: PyPI (production)' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + # publish-pypi.yml additionally gates production publishes on the + # build + release-test workflows being green for the tag. Dispatched + # on the tag ref to satisfy the pypi environment's deployment policy. + dispatch_and_watch publish-pypi.yml "$TAG" -f "tag=$TAG" -f "environment=pypi" + + - name: 'Stage 4 - Verify: pip install from PyPI' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + source "$RUNNER_TEMP/smoke.sh" + pip_smoke_install smoke-pypi + + - name: 'Stage 5 - Publish: Docker image (version tags + latest)' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + # Dispatched on the release tag itself so docker/metadata-action + # derives the semver image tags from it (requires the tag to contain + # docker-publish.yml, i.e. v0.7.1 or newer). + dispatch_and_watch docker-publish.yml "$TAG" -f "tag_latest=true" + + - name: 'Stage 5 - Verify: docker pull + smoke run from GHCR' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + image="ghcr.io/breakthrough/pyscenedetect" + docker pull "$image:$VERSION" + docker pull "$image:latest" + # `latest` must point at the build we just published. + v=$(docker image inspect "$image:$VERSION" --format '{{.Id}}') + l=$(docker image inspect "$image:latest" --format '{{.Id}}') + if [[ "$v" != "$l" ]]; then + echo "::error::latest ($l) does not match $VERSION ($v)" + exit 1 + fi + out=$(docker run --rm "$image:$VERSION" version) + echo "$out" + grep -F "$VERSION" <<<"$out" + + - name: Summary + run: | + if [[ "${{ inputs.verify-only }}" == "true" ]]; then + echo "Verification of $TAG passed (release left as draft). Re-run without verify-only to publish." + else + echo "Release $TAG verified and published:" + echo " https://pypi.org/project/scenedetect/$VERSION/" + echo " https://pypi.org/project/scenedetect-headless/$VERSION/" + echo " https://github.com/$GH_REPO/pkgs/container/pyscenedetect" + fi diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/static-analysis.yml similarity index 59% rename from .github/workflows/check-code-format.yml rename to .github/workflows/static-analysis.yml index 1cb694c2..f2d432f3 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/static-analysis.yml @@ -6,19 +6,23 @@ on: paths: - scenedetect/** - tests/** + - pyproject.toml + - .github/workflows/static-analysis.yml push: paths: - scenedetect/** - tests/** + - pyproject.toml + - .github/workflows/static-analysis.yml jobs: check_format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' cache: 'pip' @@ -26,18 +30,18 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements_headless.txt --only-binary av,opencv-python-headless - - - name: Check Code Format (yapf) - if: ${{ hashFiles('.style.yapf') != '' }} - run: | - python -m pip install --upgrade yapf toml - python -m yapf --diff --recursive scenedetect/ tests/ + python -m pip install -e .[dev] --only-binary av,opencv-python - name: Static Analysis (ruff) - if: ${{ hashFiles('.style.yapf') == '' }} run: | python -m pip install --upgrade ruff python -m ruff check python -m ruff format --check + - name: Type Check (pyright) + env: + PYRIGHT_PYTHON_FORCE_VERSION: latest + run: | + python -m pip install pyright + python -m pyright + diff --git a/.github/workflows/test-installer.yml b/.github/workflows/test-installer.yml new file mode 100644 index 00000000..45f77a5e --- /dev/null +++ b/.github/workflows/test-installer.yml @@ -0,0 +1,657 @@ +# Post-release verification of the signed Windows MSI installer. +# +# Downloads the MSI attached to a published release, then on a clean +# windows-latest runner: verifies checksum + Authenticode signature, performs a +# silent install, checks the Apps & Features registration / PATH / smoke-runs +# the CLI, and uninstalls cleanly. A second job installs the previous release's +# MSI first and upgrades over it to catch duplicate-entry and leftover-file +# regressions. + +name: Windows Installer Test + +# Dispatched by release.yml (the release orchestrator) as part of post-release +# artifact verification; can also be run manually against any published release. +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to test (e.g. v0.7-release or v0.7.1)' + required: true + previous-tag: + description: 'Release tag to upgrade from (default: auto-detect previous release)' + required: false + +# NOTE: This workflow never writes to the repository; `contents: write` is required +# only because draft-release assets are invisible to read-only tokens, and the +# release orchestrator runs this verification while the release is still a draft. +permissions: + contents: write + +jobs: + resolve: + name: Resolve Release Tags + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + msi-version: ${{ steps.resolve.outputs.msi-version }} + prev-tag: ${{ steps.resolve.outputs.prev-tag }} + prev-msi-version: ${{ steps.resolve.outputs.prev-msi-version }} + steps: + - name: Resolve current and previous release + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_PREV_TAG: ${{ inputs.previous-tag }} + run: | + set -euo pipefail + TAG="$INPUT_TAG" + if [[ -z "$TAG" ]]; then + echo "::error::No tag to test: the 'tag' input is required." + exit 1 + fi + # Both tag styles are in use (v0.7-release and v0.7.1); mirror the + # normalization release-test.yml applies to pushed tags. + VERSION="${TAG#v}" + VERSION="${VERSION%-release}" + # Pad to the numeric X.Y.Z the installer stamps into the MSI + # ProductVersion / DisplayVersion; mirrors msi_version() in + # scripts/_release_common.py ("0.7" -> "0.7.0"). + IFS=. read -r a b c _ <<<"$VERSION" + MSI_VERSION="${a}.${b:-0}.${c:-0}" + + if [[ -n "$INPUT_PREV_TAG" ]]; then + PREV_TAG="$INPUT_PREV_TAG" + else + # Newest-first list of published stable releases; the previous + # release is the entry right after the current tag. + mapfile -t tags < <(gh release list --exclude-drafts --exclude-pre-releases \ + --limit 30 --json tagName -q '.[].tagName') + PREV_TAG="" + found=0 + for i in "${!tags[@]}"; do + if [[ "${tags[$i]}" == "$TAG" ]]; then + found=1 + PREV_TAG="${tags[$((i + 1))]:-}" + break + fi + done + if [[ "$found" -eq 0 ]]; then + # Manual dispatch before the release is published: the current + # tag is not listed yet, so upgrade from the newest listed tag + # that differs from it. + for t in "${tags[@]}"; do + if [[ "$t" != "$TAG" ]]; then + PREV_TAG="$t" + break + fi + done + fi + fi + if [[ -z "$PREV_TAG" ]]; then + echo "::error::Could not determine a previous release to upgrade from; pass the 'previous-tag' input." + exit 1 + fi + PREV_VERSION="${PREV_TAG#v}" + PREV_VERSION="${PREV_VERSION%-release}" + IFS=. read -r a b c _ <<<"$PREV_VERSION" + PREV_MSI_VERSION="${a}.${b:-0}.${c:-0}" + + { + echo "tag=$TAG" + echo "version=$VERSION" + echo "msi-version=$MSI_VERSION" + echo "prev-tag=$PREV_TAG" + echo "prev-msi-version=$PREV_MSI_VERSION" + } >> "$GITHUB_OUTPUT" + echo "Testing $TAG (display $VERSION, MSI $MSI_VERSION); upgrading from $PREV_TAG (MSI $PREV_MSI_VERSION)" + + fresh-install: + name: Fresh Install + runs-on: windows-latest + needs: resolve + env: + TAG: ${{ needs.resolve.outputs.tag }} + VERSION: ${{ needs.resolve.outputs.version }} + MSI_VERSION: ${{ needs.resolve.outputs.msi-version }} + steps: + # Test videos live on the resources branch; this is the same layout + # build-windows.yml smoke-tests the portable distribution against. + - uses: actions/checkout@v5 + with: + ref: resources + + - name: Write helper functions + # Each `run:` step is a fresh pwsh process, so shared functions go into + # a file (in RUNNER_TEMP, which checkout cannot clean away) that later + # steps dot-source. + run: | + $helpers = @' + # All hives an uninstall entry could land in. The MSI installs + # per-machine (the .aip sets ALLUSERS=2 and the runner is elevated), + # so 64-bit HKLM is the expected home; the others are scanned so a + # misplaced entry fails the assertions loudly instead of hiding. + $UninstallRoots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + ) + + function Get-PySceneDetectEntries { + # Every uninstall entry (any hive, visible or hidden) for + # PySceneDetect. A healthy install has exactly two: the MSI + # ProductCode key (hidden from Apps & Features by + # ARPSYSTEMCOMPONENT=1, set in PySceneDetect.aip) and the visible + # custom key "PySceneDetect " that carries + # DisplayVersion / InstallLocation. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + foreach ($key in Get-ChildItem $root) { + $props = Get-ItemProperty $key.PSPath -ErrorAction SilentlyContinue + if ($props.DisplayName -like 'PySceneDetect*') { + [pscustomobject]@{ + KeyPath = $key.PSPath + KeyName = $key.PSChildName + DisplayName = $props.DisplayName + DisplayVersion = $props.DisplayVersion + InstallLocation = $props.InstallLocation + SystemComponent = $props.SystemComponent + } + } + } + } + } + + function Get-VisiblePySceneDetectEntries { + # The set Apps & Features actually shows: SystemComponent != 1. + Get-PySceneDetectEntries | Where-Object { $_.SystemComponent -ne 1 } + } + + function Get-UninstallKeyByName { + # The visible key's name embeds the MSI version ("PySceneDetect + # 0.7.0"), so a lookup by name across hives is a precise + # per-version existence check. + param([Parameter(Mandatory)][string]$KeyName) + foreach ($root in $UninstallRoots) { + $path = Join-Path $root $KeyName + if (Test-Path $path) { $path } + } + } + + function Invoke-Msiexec { + # msiexec detaches from the console immediately, so a bare + # `msiexec ...` would return before the Windows Installer service + # finishes (and without the real exit code); Start-Process + # -Wait -PassThru blocks and surfaces it. + param( + [Parameter(Mandatory)][string[]]$MsiArgs, + [Parameter(Mandatory)][string]$LogPath + ) + $log = Join-Path (Get-Location) $LogPath + for ($attempt = 1; $attempt -le 3; $attempt++) { + $p = Start-Process msiexec.exe -ArgumentList ($MsiArgs + @('/L*v', $log)) -Wait -PassThru + switch ($p.ExitCode) { + 0 { Write-Host "msiexec $($MsiArgs -join ' ') succeeded (exit 0)"; return } + 3010 { Write-Host 'msiexec exit 3010 (success, reboot required) - treated as success'; return } + 1618 { + # ERROR_INSTALL_ALREADY_RUNNING: runner provisioning + # sometimes still holds the machine-wide MSI mutex. + Write-Host "msiexec exit 1618 (another install in progress), attempt $attempt of 3" + if ($attempt -lt 3) { Start-Sleep -Seconds 30 } + } + default { throw "msiexec $($MsiArgs -join ' ') failed with exit code $($p.ExitCode); see $LogPath" } + } + } + throw 'msiexec still blocked by another installation (exit 1618) after 3 attempts' + } + + function Get-PathRegistryValues { + # The installer edits PATH in the registry only; neither this + # process nor its children see the change, so assertions must + # read the raw values. Machine PATH is where a per-machine + # install writes (the .aip Environment row uses the '*' system + # prefix); HKCU is read too for completeness. + $values = @() + $machine = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path -ErrorAction SilentlyContinue + if ($machine) { $values += $machine.Path } + $user = Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue + if ($user) { $values += $user.Path } + $values + } + + function Test-DirOnRegistryPath { + param([Parameter(Mandatory)][string]$Directory) + $needle = $Directory.TrimEnd('\') + foreach ($value in Get-PathRegistryValues) { + # -contains is case-insensitive, matching how Windows treats paths. + if (@($value -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $needle) { + return $true + } + } + return $false + } + + function Assert-CleanRemoval { + param([string]$InstallDir) + $entries = @(Get-PySceneDetectEntries) + if ($entries.Count -ne 0) { + $entries | Format-List | Out-String | Write-Host + throw "Expected zero uninstall entries after uninstall, found $($entries.Count)" + } + # Also match stale keys by name in case a leftover key lost its + # DisplayName value. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + $stale = @(Get-ChildItem $root | Where-Object { $_.PSChildName -like 'PySceneDetect*' }) + if ($stale.Count -ne 0) { + throw "Stale uninstall keys remain under ${root}: $($stale.PSChildName -join ', ')" + } + } + if ($InstallDir) { + if (Test-Path (Join-Path $InstallDir 'scenedetect.exe')) { + throw "scenedetect.exe still present in $InstallDir after uninstall" + } + if (Test-DirOnRegistryPath -Directory $InstallDir) { + throw "$InstallDir still present in a PATH registry value after uninstall" + } + } + Write-Host 'Verified clean removal.' + } + '@ + $dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1' + Set-Content -LiteralPath $dest -Value $helpers + Write-Host "Wrote $dest" + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release download $env:TAG --pattern 'PySceneDetect-*-win64.msi' --pattern 'SHA256SUMS' --dir dist + if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:TAG" } + $expected = "PySceneDetect-$env:VERSION-win64.msi" + $msis = @(Get-ChildItem dist -Filter '*.msi') + if ($msis.Count -ne 1 -or $msis[0].Name -ne $expected) { + throw "Expected exactly one MSI named $expected, got: $($msis.Name -join ', ')" + } + if (-not (Test-Path dist\SHA256SUMS)) { throw 'SHA256SUMS missing from release assets' } + + - name: Verify checksum and Authenticode signature + run: | + $msi = "dist\PySceneDetect-$env:VERSION-win64.msi" + $name = Split-Path $msi -Leaf + # SHA256SUMS lines are ` ` (two spaces; the sha256sum -c + # format written by scripts/finalize_windows_dist.py). + $sums = @{} + foreach ($line in Get-Content dist\SHA256SUMS) { + $hash, $entry = $line -split ' ', 2 + $sums[$entry.Trim()] = $hash.Trim() + } + if (-not $sums.ContainsKey($name)) { throw "SHA256SUMS has no entry for $name" } + $actual = (Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $sums[$name].ToLowerInvariant()) { + throw "SHA256 mismatch for ${name}: expected $($sums[$name]), got $actual" + } + Write-Host "Checksum OK: $actual" + $sig = Get-AuthenticodeSignature $msi + if ($sig.Status -ne 'Valid') { + throw "Authenticode signature status is '$($sig.Status)' (expected 'Valid')" + } + Write-Host "Signature OK: $($sig.SignerCertificate.Subject)" + + - name: Install MSI + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/i', $msi, '/qn', '/norestart') -LogPath install.log + + - name: Verify installation + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + # Locate the install via the registry - never hard-code the path: the + # .aip uses ALLUSERS=2 with a MixedAllUsersInstallLocation custom + # action, so APPDIR is resolved at install time. + $visible = @(Get-VisiblePySceneDetectEntries) + if ($visible.Count -ne 1) { + Get-PySceneDetectEntries | Format-List | Out-String | Write-Host + throw "Expected exactly 1 visible uninstall entry, found $($visible.Count)" + } + $entry = $visible[0] + if ($entry.DisplayVersion -ne $env:MSI_VERSION) { + throw "DisplayVersion is '$($entry.DisplayVersion)', expected '$env:MSI_VERSION'" + } + if (-not $entry.InstallLocation) { throw 'InstallLocation is empty' } + $exe = Join-Path $entry.InstallLocation 'scenedetect.exe' + if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($entry.InstallLocation)" } + # Persist the discovered install dir for the later steps. + Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)" + # Invoke by absolute path: the installer's PATH change is + # registry-only and not visible to this already-running process. + $out = & $exe version 2>&1 | Out-String + Write-Host $out + if ($LASTEXITCODE -ne 0) { throw "scenedetect version exited with $LASTEXITCODE" } + if (-not $out.Contains($env:VERSION)) { throw "version output does not mention $env:VERSION" } + if (-not (Test-DirOnRegistryPath -Directory $entry.InstallLocation)) { + throw "$($entry.InstallLocation) was not added to any PATH registry value" + } + Write-Host 'Install verified.' + + - name: Functional smoke test + run: | + # Same clip and command build-windows.yml uses to smoke-test the + # portable distribution. + $exe = Join-Path $env:INSTALL_DIR 'scenedetect.exe' + & $exe -i tests/resources/goldeneye.mp4 detect-content time --end 10s + if ($LASTEXITCODE -ne 0) { throw "smoke test exited with $LASTEXITCODE" } + + - name: Uninstall MSI + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + # Uninstall via the same local MSI file (not the ProductCode) so a + # broken product registration surfaces as a failure here. + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/x', $msi, '/qn', '/norestart') -LogPath uninstall.log + + - name: Verify clean removal + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + Assert-CleanRemoval -InstallDir $env:INSTALL_DIR + + - name: Upload msiexec logs + if: failure() + uses: actions/upload-artifact@v6 + with: + name: fresh-install-logs + path: '*.log' + if-no-files-found: ignore + + upgrade: + name: Upgrade From Previous Release + runs-on: windows-latest + needs: resolve + env: + TAG: ${{ needs.resolve.outputs.tag }} + VERSION: ${{ needs.resolve.outputs.version }} + MSI_VERSION: ${{ needs.resolve.outputs.msi-version }} + PREV_TAG: ${{ needs.resolve.outputs.prev-tag }} + PREV_MSI_VERSION: ${{ needs.resolve.outputs.prev-msi-version }} + steps: + - name: Write helper functions + # Identical to the fresh-install helpers; jobs cannot share script + # blocks, so the definitions are duplicated per job. + run: | + $helpers = @' + # All hives an uninstall entry could land in. The MSI installs + # per-machine (the .aip sets ALLUSERS=2 and the runner is elevated), + # so 64-bit HKLM is the expected home; the others are scanned so a + # misplaced entry fails the assertions loudly instead of hiding. + $UninstallRoots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + ) + + function Get-PySceneDetectEntries { + # Every uninstall entry (any hive, visible or hidden) for + # PySceneDetect. A healthy install has exactly two: the MSI + # ProductCode key (hidden from Apps & Features by + # ARPSYSTEMCOMPONENT=1, set in PySceneDetect.aip) and the visible + # custom key "PySceneDetect " that carries + # DisplayVersion / InstallLocation. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + foreach ($key in Get-ChildItem $root) { + $props = Get-ItemProperty $key.PSPath -ErrorAction SilentlyContinue + if ($props.DisplayName -like 'PySceneDetect*') { + [pscustomobject]@{ + KeyPath = $key.PSPath + KeyName = $key.PSChildName + DisplayName = $props.DisplayName + DisplayVersion = $props.DisplayVersion + InstallLocation = $props.InstallLocation + SystemComponent = $props.SystemComponent + } + } + } + } + } + + function Get-VisiblePySceneDetectEntries { + # The set Apps & Features actually shows: SystemComponent != 1. + Get-PySceneDetectEntries | Where-Object { $_.SystemComponent -ne 1 } + } + + function Get-UninstallKeyByName { + # The visible key's name embeds the MSI version ("PySceneDetect + # 0.7.0"), so a lookup by name across hives is a precise + # per-version existence check. + param([Parameter(Mandatory)][string]$KeyName) + foreach ($root in $UninstallRoots) { + $path = Join-Path $root $KeyName + if (Test-Path $path) { $path } + } + } + + function Invoke-Msiexec { + # msiexec detaches from the console immediately, so a bare + # `msiexec ...` would return before the Windows Installer service + # finishes (and without the real exit code); Start-Process + # -Wait -PassThru blocks and surfaces it. + param( + [Parameter(Mandatory)][string[]]$MsiArgs, + [Parameter(Mandatory)][string]$LogPath + ) + $log = Join-Path (Get-Location) $LogPath + for ($attempt = 1; $attempt -le 3; $attempt++) { + $p = Start-Process msiexec.exe -ArgumentList ($MsiArgs + @('/L*v', $log)) -Wait -PassThru + switch ($p.ExitCode) { + 0 { Write-Host "msiexec $($MsiArgs -join ' ') succeeded (exit 0)"; return } + 3010 { Write-Host 'msiexec exit 3010 (success, reboot required) - treated as success'; return } + 1618 { + # ERROR_INSTALL_ALREADY_RUNNING: runner provisioning + # sometimes still holds the machine-wide MSI mutex. + Write-Host "msiexec exit 1618 (another install in progress), attempt $attempt of 3" + if ($attempt -lt 3) { Start-Sleep -Seconds 30 } + } + default { throw "msiexec $($MsiArgs -join ' ') failed with exit code $($p.ExitCode); see $LogPath" } + } + } + throw 'msiexec still blocked by another installation (exit 1618) after 3 attempts' + } + + function Get-PathRegistryValues { + # The installer edits PATH in the registry only; neither this + # process nor its children see the change, so assertions must + # read the raw values. Machine PATH is where a per-machine + # install writes (the .aip Environment row uses the '*' system + # prefix); HKCU is read too for completeness. + $values = @() + $machine = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path -ErrorAction SilentlyContinue + if ($machine) { $values += $machine.Path } + $user = Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue + if ($user) { $values += $user.Path } + $values + } + + function Test-DirOnRegistryPath { + param([Parameter(Mandatory)][string]$Directory) + $needle = $Directory.TrimEnd('\') + foreach ($value in Get-PathRegistryValues) { + # -contains is case-insensitive, matching how Windows treats paths. + if (@($value -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $needle) { + return $true + } + } + return $false + } + + function Assert-CleanRemoval { + param([string]$InstallDir) + $entries = @(Get-PySceneDetectEntries) + if ($entries.Count -ne 0) { + $entries | Format-List | Out-String | Write-Host + throw "Expected zero uninstall entries after uninstall, found $($entries.Count)" + } + # Also match stale keys by name in case a leftover key lost its + # DisplayName value. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + $stale = @(Get-ChildItem $root | Where-Object { $_.PSChildName -like 'PySceneDetect*' }) + if ($stale.Count -ne 0) { + throw "Stale uninstall keys remain under ${root}: $($stale.PSChildName -join ', ')" + } + } + if ($InstallDir) { + if (Test-Path (Join-Path $InstallDir 'scenedetect.exe')) { + throw "scenedetect.exe still present in $InstallDir after uninstall" + } + if (Test-DirOnRegistryPath -Directory $InstallDir) { + throw "$InstallDir still present in a PATH registry value after uninstall" + } + } + Write-Host 'Verified clean removal.' + } + '@ + $dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1' + Set-Content -LiteralPath $dest -Value $helpers + Write-Host "Wrote $dest" + + - name: Download previous release MSI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release download $env:PREV_TAG --pattern '*-win64.msi' --dir prev + if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:PREV_TAG" } + $msis = @(Get-ChildItem prev -Filter '*.msi') + if ($msis.Count -ne 1) { + throw "Expected exactly one MSI from $env:PREV_TAG, got: $($msis.Name -join ', ')" + } + # Older releases may not ship SHA256SUMS, so the previous MSI is + # deliberately not checksummed; only the new MSI under test is. + Add-Content $env:GITHUB_ENV "PREV_MSI=$($msis[0].FullName)" + + - name: Install previous release + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + Invoke-Msiexec -MsiArgs @('/i', $env:PREV_MSI, '/qn', '/norestart') -LogPath install-prev.log + # Sanity check the baseline before upgrading over it. + $visible = @(Get-VisiblePySceneDetectEntries) + if ($visible.Count -ne 1) { + Get-PySceneDetectEntries | Format-List | Out-String | Write-Host + throw "Expected exactly 1 visible uninstall entry after baseline install, found $($visible.Count)" + } + if ($visible[0].DisplayVersion -ne $env:PREV_MSI_VERSION) { + throw "Baseline DisplayVersion is '$($visible[0].DisplayVersion)', expected '$env:PREV_MSI_VERSION'" + } + $exe = Join-Path $visible[0].InstallLocation 'scenedetect.exe' + if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($visible[0].InstallLocation)" } + & $exe version + if ($LASTEXITCODE -ne 0) { throw "baseline scenedetect version exited with $LASTEXITCODE" } + Add-Content $env:GITHUB_ENV "OLD_INSTALL_DIR=$($visible[0].InstallLocation)" + + - name: Download new release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release download $env:TAG --pattern 'PySceneDetect-*-win64.msi' --pattern 'SHA256SUMS' --dir dist + if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:TAG" } + $expected = "PySceneDetect-$env:VERSION-win64.msi" + $msis = @(Get-ChildItem dist -Filter '*.msi') + if ($msis.Count -ne 1 -or $msis[0].Name -ne $expected) { + throw "Expected exactly one MSI named $expected, got: $($msis.Name -join ', ')" + } + if (-not (Test-Path dist\SHA256SUMS)) { throw 'SHA256SUMS missing from release assets' } + + - name: Verify checksum and Authenticode signature + run: | + $msi = "dist\PySceneDetect-$env:VERSION-win64.msi" + $name = Split-Path $msi -Leaf + # SHA256SUMS lines are ` ` (two spaces; the sha256sum -c + # format written by scripts/finalize_windows_dist.py). + $sums = @{} + foreach ($line in Get-Content dist\SHA256SUMS) { + $hash, $entry = $line -split ' ', 2 + $sums[$entry.Trim()] = $hash.Trim() + } + if (-not $sums.ContainsKey($name)) { throw "SHA256SUMS has no entry for $name" } + $actual = (Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $sums[$name].ToLowerInvariant()) { + throw "SHA256 mismatch for ${name}: expected $($sums[$name]), got $actual" + } + Write-Host "Checksum OK: $actual" + $sig = Get-AuthenticodeSignature $msi + if ($sig.Status -ne 'Valid') { + throw "Authenticode signature status is '$($sig.Status)' (expected 'Valid')" + } + Write-Host "Signature OK: $($sig.SignerCertificate.Subject)" + + - name: Install new release over previous + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/i', $msi, '/qn', '/norestart') -LogPath install-upgrade.log + + - name: Verify upgrade + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $visible = @(Get-VisiblePySceneDetectEntries) + if ($visible.Count -ne 1) { + Get-PySceneDetectEntries | Format-List | Out-String | Write-Host + throw "Expected exactly 1 visible uninstall entry after upgrade, found $($visible.Count)" + } + $entry = $visible[0] + if ($entry.DisplayVersion -ne $env:MSI_VERSION) { + throw "DisplayVersion is '$($entry.DisplayVersion)', expected '$env:MSI_VERSION'" + } + # The visible key name embeds the version ("PySceneDetect + # "), so the old key vanishing from every hive is a + # precise duplicate-Apps-&-Features-entry check. + $stale = @(Get-UninstallKeyByName -KeyName "PySceneDetect $env:PREV_MSI_VERSION") + if ($stale.Count -ne 0) { + throw "Previous version's uninstall key still present: $($stale -join ', ')" + } + # Belt and braces: no entry anywhere (visible or hidden) may still + # report the old version. + $old = @(Get-PySceneDetectEntries | Where-Object { $_.DisplayVersion -eq $env:PREV_MSI_VERSION }) + if ($old.Count -ne 0) { + $old | Format-List | Out-String | Write-Host + throw "Found $($old.Count) uninstall entries still at $env:PREV_MSI_VERSION" + } + if (-not $entry.InstallLocation) { throw 'InstallLocation is empty after upgrade' } + $exe = Join-Path $entry.InstallLocation 'scenedetect.exe' + if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($entry.InstallLocation)" } + $out = & $exe version 2>&1 | Out-String + Write-Host $out + if ($LASTEXITCODE -ne 0) { throw "scenedetect version exited with $LASTEXITCODE" } + if (-not $out.Contains($env:VERSION)) { throw "version output does not mention $env:VERSION" } + # If the upgrade relocated the install, the old copy must be gone. + if ($env:OLD_INSTALL_DIR.TrimEnd('\') -ne $entry.InstallLocation.TrimEnd('\')) { + if (Test-Path (Join-Path $env:OLD_INSTALL_DIR 'scenedetect.exe')) { + throw "Old install at $env:OLD_INSTALL_DIR still present after relocating upgrade" + } + } + Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)" + Write-Host 'Upgrade verified.' + + - name: Uninstall new MSI + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/x', $msi, '/qn', '/norestart') -LogPath uninstall.log + + - name: Verify clean removal + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + Assert-CleanRemoval -InstallDir $env:INSTALL_DIR + + - name: Upload msiexec logs + if: failure() + uses: actions/upload-artifact@v6 + with: + name: upgrade-logs + path: '*.log' + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index c6d36daa..b2f656fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ docs/_build/ +docs/STYLE.md website/build/ +scripts/local/ tests/resources/* *.mp4 *.jpg @@ -9,11 +11,17 @@ tests/resources/* *.mkv *.m4v *.csv -benchmarks/BCC/*.mp4 -*.txt -benchmarks/RAI/*.mp4 *.txt +benchmark/BBC/ +benchmark/AutoShot/ +benchmark/ClipShots/ +benchmark/results/ + +packaging/windows/.version_info +packaging/windows/installer/PySceneDetect.back*.aip +packaging/windows/installer/PySceneDetect-*.msi +packaging/windows/installer/PySceneDetect-cache/ # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore @@ -87,3 +95,4 @@ dmypy.json .pyre/ .pytype/ cython_debug/ +test_clips/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..039790f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# +# 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 + +# 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 the scenedetect-headless variant: full program (CLI + opencv-python-headless) +# with the optional media backends. The repo root pyproject builds scenedetect-core +# (library only, no CLI), so swap in the headless variant pyproject first. +# 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 \ + cp packaging/variants/pyproject-scenedetect-headless.toml pyproject.toml && \ + pip install ".[pyav,moviepy]" && \ + # TODO(https://github.com/Zulko/moviepy/issues/2553): moviepy caps pillow<12.0, but 11.x has + # CVEs only fixed in 12.3.0+. Tests pass against 12.3.0; drop this once moviepy lifts the cap. + pip install "pillow==12.3.0" + +# Switch to the non-root user +USER scenedetect + +# The default behavior is to run the CLI +ENTRYPOINT ["scenedetect"] + 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/MANIFEST.in b/MANIFEST.in index bca665ee..cf223d6b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,12 +1,12 @@ recursive-exclude .github * -recursive-exclude dist * +recursive-exclude packaging * +recursive-exclude scripts * recursive-exclude docs * recursive-exclude website * exclude * include README.md include LICENSE include pyproject.toml -include setup.cfg include scenedetect.cfg -include dist/package-info.rst +include packaging/package-info.rst recursive-include docs * diff --git a/README.md b/README.md index 2855638a..f3508a94 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -![PySceneDetect](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/main/website/pages/img/pyscenedetect_logo_small.png) -========================================================== -Video Cut Detection and Analysis Tool ----------------------------------------------------------- + + + PySceneDetect + + +# Video Cut Detection and Analysis Tool [![Build Status](https://img.shields.io/github/actions/workflow/status/Breakthrough/PySceneDetect/build.yml)](https://github.com/Breakthrough/PySceneDetect/actions) [![PyPI Status](https://img.shields.io/pypi/status/scenedetect.svg)](https://pypi.python.org/pypi/scenedetect/) @@ -11,7 +13,7 @@ Video Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.7 (August 24, 2025) +### Latest Release: v0.7.1 (July 21, 2026) **Website**: [scenedetect.com](https://www.scenedetect.com) @@ -25,9 +27,9 @@ Video Cut Detection and Analysis Tool **Quick Install**: - pip install scenedetect[opencv] --upgrade + pip install scenedetect --upgrade -Requires ffmpeg/mkvmerge for video splitting support. Windows builds (MSI installer/portable ZIP) can be found on [the download page](https://scenedetect.com/download/). +Requires ffmpeg/mkvmerge for video splitting support. Windows builds (MSI installer/portable ZIP) can be found on [the download page](https://scenedetect.com/download/). A Docker image with all dependencies included is available as [`ghcr.io/breakthrough/pyscenedetect`](https://github.com/Breakthrough/PySceneDetect/pkgs/container/pyscenedetect). ---------------------------------------------------------- @@ -47,13 +49,20 @@ Skip the first 10 seconds of the input video: More examples can be found throughout [the documentation](https://www.scenedetect.com/docs/latest/cli.html). +**Quick Start (Docker)**: + +The same commands work without installing anything using [the official Docker image](https://github.com/Breakthrough/PySceneDetect/pkgs/container/pyscenedetect), which includes all dependencies (`ffmpeg`/`mkvmerge` included). Mount the folder containing your videos and use it for input/output paths: + + docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect -i /files/video.mp4 split-video -o /files + **Quick Start (Python API)**: To get started, there is a high level function in the library that performs content-aware scene detection on a video (try it from a Python prompt): ```python from scenedetect import detect, ContentDetector -scene_list = detect('my_video.mp4', ContentDetector()) + +scene_list = detect("my_video.mp4", ContentDetector()) ``` `scene_list` will now be a list containing the start/end times of all scenes found in the video. There also exists a two-pass version `AdaptiveDetector` which handles fast camera movement better, and `ThresholdDetector` for handling fade out/fade in events. @@ -62,20 +71,28 @@ Try calling `print(scene_list)`, or iterating over each scene: ```python from scenedetect import detect, ContentDetector -scene_list = detect('my_video.mp4', ContentDetector()) + +scene_list = detect("my_video.mp4", ContentDetector()) for i, scene in enumerate(scene_list): - print(' Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( - i+1, - scene[0].get_timecode(), scene[0].frame_num, - scene[1].get_timecode(), scene[1].frame_num,)) + print( + " Scene %2d: Start %s / Frame %d, End %s / Frame %d" + % ( + i + 1, + scene[0].get_timecode(), + scene[0].frame_num, + scene[1].get_timecode(), + scene[1].frame_num, + ) + ) ``` We can also split the video into each scene if `ffmpeg` is installed (`mkvmerge` is also supported): ```python from scenedetect import detect, ContentDetector, split_video_ffmpeg -scene_list = detect('my_video.mp4', ContentDetector()) -split_video_ffmpeg('my_video.mp4', scene_list) + +scene_list = detect("my_video.mp4", ContentDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) ``` For more advanced usage, the API is highly configurable, and can easily integrate with any pipeline. This includes using different detection algorithms, splitting the input video, and much more. The following example shows how to implement a function similar to the above, but using [the `scenedetect` API](https://www.scenedetect.com/docs/latest/api.html): @@ -85,12 +102,12 @@ from scenedetect import open_video, SceneManager, split_video_ffmpeg from scenedetect.detectors import ContentDetector from scenedetect.video_splitter import split_video_ffmpeg + def split_video_into_scenes(video_path, threshold=27.0): # Open our video, create a scene manager, and add a detector. video = open_video(video_path) scene_manager = SceneManager() - scene_manager.add_detector( - ContentDetector(threshold=threshold)) + scene_manager.add_detector(ContentDetector(threshold=threshold)) scene_manager.detect_scenes(video, show_progress=True) scene_list = scene_manager.get_scene_list() split_video_ffmpeg(video_path, scene_list, show_progress=True) @@ -100,20 +117,20 @@ See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for mo **Benchmark**: -We evaluate the performance of different detectors in terms of accuracy and processing speed. See the [benchmark report](benchmark/README.md) for details. +We evaluate the performance of different detectors in terms of accuracy and processing speed. See [www.scenedetect.com/benchmarks](https://www.scenedetect.com/benchmarks/) for results, or the [benchmark report](benchmark/README.md) for details on the datasets and methodology. ## Reference - [Documentation](https://www.scenedetect.com/docs/) (covers application and Python API) - [CLI Example](https://www.scenedetect.com/cli/) - - [Config File](https://www.scenedetect.com/docs/0.6.4/cli/config_file.html) + - [Config File](https://www.scenedetect.com/docs/latest/cli/config_file.html) ## Help & Contributing Please submit any bugs/issues or feature requests to [the Issue Tracker](https://github.com/Breakthrough/PySceneDetect/issues). Before submission, ensure you search through existing issues (both open and closed) to avoid creating duplicate entries. Pull requests are welcome and encouraged. PySceneDetect is released under the BSD 3-Clause license, and submitted code should be compliant. -For help or other issues, you can join [the official PySceneDetect Discord Server](https://discord.gg/H83HbJngk7), submit an issue/bug report [here on Github](https://github.com/Breakthrough/PySceneDetect/issues), or contact me via [my website](http://www.bcastell.com/about/). +For help or other issues, you can join [the official PySceneDetect Discord Server](https://discord.gg/H83HbJngk7), submit an issue/bug report [here on Github](https://github.com/Breakthrough/PySceneDetect/issues), or contact me via [my website](https://bcastell.com/about/). ## Code Signing @@ -125,5 +142,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/RELEASE-PLAN.md b/RELEASE-PLAN.md new file mode 100644 index 00000000..bcd86ce7 --- /dev/null +++ b/RELEASE-PLAN.md @@ -0,0 +1,73 @@ +# PySceneDetect Release Checklist + +Use one copy per release, copy into a pull request and check each box as steps are completed. +Optional: version referenced below as `X.Y[.Z]` - replace with the real version throughout. + +## 1. Version Identifiers, Branch Prep + +- [ ] Create release branch `releases/X.Y[.Z]` off `main` (each release, including patches, gets its own branch - e.g. `releases/0.6.7`, `releases/0.7.1`); fast-forward it to `main` as release work lands. +- [ ] Bump `__version__` in `scenedetect/__init__.py` +- [ ] Bump `docs/LATEST_VERSION` for any stable release: it must match the `releases/X.Y[.Z]` branch suffix for `generate-docs.yml` to update `docs/latest` +- [ ] Regular release: No `-dev` suffix or other, pre-release: has suffix `-dev0`, `-dev1`, ... + +## 2. Documentation, Website, Changelog + +- [ ] Docstrings / API docs reflect any signature changes (`cd docs/ && make html` builds clean). +- [ ] `docs/api/migration_guide.rst` updated if any public API changed. +- [ ] Docstring examples all run correctly, nothing references removed or deprecated symbols. +- [ ] Changelog has release notes for major/minor release, all features, breaking changes, bug fixes, and known issues are documented. +- [ ] `website/pages/download.md` updated with the new version / installer link / release date. +- [ ] `website/pages/changelog.md`: move the release changes from the **Development** section at the bottom to the top. +- [ ] `website/pages/index.md`: Latest release version and date updated. + +## 3. Tests + +- [ ] Static analysis passing (ruff + pyright). +- [ ] Unit tests green locally and in CI: `pytest -vv` (should collect `-m 'not release'` by default). +- [ ] Release test suite green: manually trigger or make a release candidate tag, all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the OS and Python version matrix. +- [ ] `pip-audit` clean (or exceptions documented in the changelog). + +## 4. Prepare Windows Distribution + +- [ ] Update `packaging/windows/requirements.txt` and bump bundled ffmpeg version in `appveyor.yml` +- [ ] Run AppVeyor build on release branch, ensure resulting portable distribution and MSI installer are correct + +> **GUI required for structural changes.** `scripts/update_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: +> +> - Moving the .aip or its source tree (the build's `SourcePath` references are stored relative to the .aip and aren't rewritten by `/NewSync`. +> - Adding/removing build configurations, features, or prerequisites. +> - Install directory layout (`APPDIR` location), or per-component attributes. +> - Editing dialog layouts, branding bitmaps, install sequences, custom actions, file associations, or shortcuts. + +## 5. Tag & Draft Release + +- [ ] Final commit on `releases/X.Y[.Z]`: "Release vX.Y[.Z]". +- [ ] Tag `vX.Y[.Z]` on that commit and push (the legacy `vX.Y[.Z]-release` form is also accepted by all workflows). Wait for all tests/builds to pass. +- [ ] Approve code signing request on SignPath, download `scenedetect-signed.zip` +- [ ] Finalize Windows artifacts locally (CI can't do this - signing happens after the AppVeyor build, so the post-signing steps must run locally): + - Create `dist/signed/` and drop `scenedetect-signed.zip` (from SignPath) into it. No other inputs needed - the portable .zip is rebuilt from the signed .msi via `msiexec /a`, eliminating the AppVeyor download. + - Run `python scripts/finalize_windows_dist.py`. This extracts the signed `.msi` from the bundle, runs `msiexec /a` to recover the installed file tree, repacks it as the portable `.zip` with 7-Zip, writes `PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`, and then runs `scripts/validate_release.py` to verify filenames, hashes, Authenticode signatures, MSI/zip parity, and frozen `.exe` smoke tests. +- [ ] Draft release on Github using the tagged commit: include full changelog & release notes, signed portable .ZIP, signed .MSI installer, Python .whl/.tar.gz packages, and checksum manifests (`PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`) +- [ ] Verify all artifacts uploaded to Github release are valid and named correctly +- [ ] Smoke-test all release artifacts + +## 6. Publish & Release Checks + +- [ ] Dispatch `release.yml` (Release Orchestrator) with the release tag while the Github release is still a **draft**. It runs the verify-then-publish ladder (MSI install/upgrade test -> TestPyPI -> publish Github release -> PyPI -> Docker), verifying each stage before the next; `verify-only` stops before anything goes public. See the header of `release.yml` for details. +- [ ] Verify both projects: https://pypi.org/project/scenedetect/ and https://pypi.org/project/scenedetect-headless/. +- [ ] Deploy website: `generate-website.yml` +- [ ] Deploy docs: `generate-docs.yml` +- [ ] Merge release branch back into `main`, verify `docs/LATEST_VERSION` is correct +- [ ] [Manually dispatch `generate-docs.yml`](https://github.com/Breakthrough/PySceneDetect/actions/workflows/generate-docs.yml) against `releases/X.Y` to update www.scenedetect.com/docs/latest +- [ ] Smoke-test PyPI release: in a fresh venv, `pip install scenedetect==X.Y.Z`; CLI launches and `scenedetect --version` looks correct. +- [ ] Verify download links on website are correct, PyPI project page is up to date and correct. +- [ ] Clear / archive release-scoped tracking files (`tracking.md`, any release-specific TODOs). +- [ ] Announce: project site, relevant issues / discussions closed and linked to the release. + +--- + +## Notes + +- **Branching model**: work spans multiple commits on `releases/X.Y[.Z]`; the final one gets the `vX.Y[.Z]` tag which gates the release-test workflow. A passing release-test is a hard prerequisite for publishing. +- **Version consistency** is enforced in two places (`__init__.py`, `PySceneDetect.aip`). The `static` job of `release-test.yml` checks `__init__.py` against the tag and verifies the changelog has a matching `## PySceneDetect X.Y` heading; the installer parity is checked by `scripts/pre_release.py --release`. +- **Changelog convention**: the in-development section lives at the *bottom* of `website/pages/changelog.md` under the "Development" heading - don't move it to the top. diff --git a/appveyor.yml b/appveyor.yml index 73a245c4..0ffafc7c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,12 +2,23 @@ build: false -# Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release +cache: + # FFmpeg self-invalidates via %ffmpeg_version% in the filename; AdvInst MSI and + # Inkscape rarely need refresh and have `if not exist` install guards, so we + # don't tie them to appveyor.yml (any edit there would force a cold-cache + # reinstall of all three and blow past the 10-minute build limit). + - 'ffmpeg-%ffmpeg_version%-full_build.7z' + - 'packaging\windows\installer\advinst.msi' + - '%LOCALAPPDATA%\uv\cache -> pyproject.toml' + - 'C:\Program Files\Inkscape' + +# Branches applies to tags as well. We only build on tagged releases of the form +# vX.Y[.Z] (the legacy -release suffix is also accepted, matching the GitHub workflows). branches: only: - main - /releases\/.+/ - - /v.+-release/ + - /v.+/ skip_tags: false skip_non_tags: true @@ -20,7 +31,7 @@ environment: secure: QRCPoNYF1nqgXDn7pHgBzg== ai_license_salt: secure: +Gy+SRk8JUsaM+5pMEKITiJxdLilrxHpkKlrZzR3C9DPwdgYLGxt5sJn6uXuAJg7e6JsKHcT7tRks/HcSKkHPw== - ffmpeg_version: "8.0" + ffmpeg_version: "8.1.2" # SignPath Config for Code Signing deploy: @@ -37,55 +48,77 @@ install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - - python -m pip install --upgrade pip build wheel virtualenv setuptools - - python -m pip install -r docs/requirements.txt - - python -m pip install --upgrade -r dist/requirements_windows.txt --no-binary imageio-ffmpeg - # Checkout build resources and third party software used for testing. - - git checkout refs/remotes/origin/resources -- dist/ - - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z + - python -m pip install uv + - uv pip install --system .[docs] + - uv pip install --system -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg + # TODO(https://github.com/Zulko/moviepy/issues/2553): moviepy caps pillow<12.0, but 11.x has + # CVEs only fixed in 12.3.0+. Installed as a separate step since a pin in requirements.txt + # would fail strict resolution against moviepy's constraint. Tests pass against 12.3.0; + # drop this once moviepy lifts the cap. + - uv pip install --system pillow==12.3.0 + - if not exist ffmpeg-%ffmpeg_version%-full_build.7z appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r + # moviepy.config reads FFMPEG_BINARY (which routes through imageio_ffmpeg) at import time. + # `--no-binary imageio-ffmpeg` strips the bundled ffmpeg, so point it at the GyanD copy + # we just extracted; otherwise pre_release.py and pyinstaller analysis crash on + # `import scenedetect`. The runtime hook (pyi_rth_scenedetect.py) does the same at exe runtime. - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' + # Inkscape is required by scripts/pre_release.py --release (regenerates installer JPGs + # from the master SVG). Not preinstalled on the AppVeyor VS2019 image; cached + # in `C:\Program Files\Inkscape` (see cache: section) so we only re-install when + # the cache is busted (appveyor.yml changes). + - if not exist "C:\Program Files\Inkscape\bin\inkscape.exe" choco install inkscape -y --no-progress + - 'SET PATH=%PATH%;C:\Program Files\Inkscape\bin' - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING WINDOWS EXE * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - # Build Windows .EXE and create portable .ZIP - - python dist/pre_release.py --release - - pyinstaller dist/scenedetect.spec - - sphinx-build -b singlehtml docs dist/scenedetect/docs - - mkdir dist\scenedetect\thirdparty - - move LICENSE dist\scenedetect\ - - move dist\windows\README* dist\scenedetect\ - - move dist\windows\LICENSE* dist\scenedetect\thirdparty\ - - move scenedetect\_thirdparty\LICENSE* dist\scenedetect\thirdparty\ - - copy dist\ffmpeg\ffmpeg.exe dist\scenedetect\ - - move dist\ffmpeg\LICENSE dist\scenedetect\thirdparty\LICENSE-FFMPEG - - cd dist/scenedetect - - 7z a ../scenedetect-win64.zip * - - cd ../.. + # Build Windows .EXE and create portable .ZIP. The staging script copies + # ffmpeg.exe + LICENSE from --ffmpeg-dir, third-party licenses, the project + # LICENSE/README, and sphinx docs into dist/scenedetect/, then emits the + # portable .zip - keeps CI and local builds in sync (see scripts/stage_windows_dist.py). + - python scripts/pre_release.py --release + - pyinstaller packaging/windows/scenedetect.spec + - python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING MSI INSTALLER * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # Download, install, and register AdvancedInstaller - - cd dist/installer + - cd packaging/windows/installer - ps: iex ((New-Object Net.WebClient).DownloadString('https://raw.githubusercontent.com/appveyor/secure-file/master/install.ps1')) - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi + - if not exist advinst.msi appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.9.1\\bin\\x86' + # Resolve the installed Advanced Installer bin path dynamically - the upstream + # MSI is unversioned so the directory name (Advanced Installer X.Y.Z) drifts. + - ps: $aiBin = (Get-ChildItem 'C:\Program Files (x86)\Caphyon\Advanced Installer*\bin\x86' | Sort-Object FullName -Descending | Select-Object -First 1).FullName; Add-Content $env:APPVEYOR_BUILD_FOLDER\ai_path.txt $aiBin + - set /p AI_BIN=<%APPVEYOR_BUILD_FOLDER%\ai_path.txt + - 'SET PATH=%PATH%;%AI_BIN%' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" + - cd ../../.. + # Re-sync APPDIR from CI's dist/scenedetect (handles drift between local and + # CI pyinstaller output - new transitive deps, Python patch updates, etc.). + # Does not touch version/GUID fields - those are committed to the .aip on the + # release tag and must stay stable across rebuilds for upgrade-chain integrity. + # On non-tag builds, also pass --dev so the MSI is named PySceneDetect-{ver}-dev-win64.msi + # (keeps dev artifacts distinguishable from signed releases). + - if "%APPVEYOR_REPO_TAG%"=="true" (python scripts/update_installer.py --sync-only) else (python scripts/update_installer.py --sync-only --dev) + # Snapshot the post-sync .aip and the actual payload tree as build artifacts. + # The committed .aip is a baseline; CI adapts it to its own pyinstaller output + # and we never write back to git, so these snapshots are the authoritative + # record of what each MSI was built from (for audit / release forensics). + - copy packaging\windows\installer\PySceneDetect.aip dist\PySceneDetect.aip # Create MSI installer - - AdvancedInstaller.com /build PySceneDetect.aip - - cd ../.. + - AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * PACKAGING BUILD ARTIFACTS * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # Zip all resources together for code signing + - move packaging\windows\installer\PySceneDetect-*.msi dist\ - cd dist - - move installer\PySceneDetect-*.msi . - cp scenedetect\scenedetect.exe . - 7z a scenedetect-signed.zip scenedetect.exe PySceneDetect-*.msi - cd .. @@ -110,9 +143,14 @@ test_script: - scenedetect.exe -i ../../tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s artifacts: - # Portable ZIP - - path: dist/scenedetect-win64.zip - name: PySceneDetect-win64_portable + # Portable ZIP (named PySceneDetect-X.Y.Z-win64.zip by stage_windows_dist.py) + - path: dist/PySceneDetect-*-win64.zip + name: PySceneDetect-win64 # MSI Installer + .EXE Bundle for Signing - path: dist/scenedetect-signed.zip name: PySceneDetect-win64_installer + # Build provenance: post-sync .aip and the portable payload manifest. + - path: dist/PySceneDetect.aip + name: PySceneDetect-build-manifest-aip + - path: dist/PySceneDetect-*.manifest.txt + name: PySceneDetect-build-manifest-payload diff --git a/benchmark/README.md b/benchmark/README.md index 52012e58..180b0d77 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,83 +1,194 @@ -# Benchmarking PySceneDetect -This repository benchmarks the performance of PySceneDetect in terms of both latency and accuracy. -We evaluate it using the standard dataset for video shot detection: [BBC](https://zenodo.org/records/14865504) and [AutoShot](https://drive.google.com/file/d/17diRkLlNUUjHDooXdqFUTXYje2-x4Yt6/view?usp=sharing). - -## Dataset Download -### BBC -``` -# annotation -wget -O BBC/fixed.zip https://zenodo.org/records/14873790/files/fixed.zip -unzip BBC/fixed.zip -d BBC -rm -rf BBC/fixed.zip - -# videos -wget -O BBC/videos.zip https://zenodo.org/records/14873790/files/videos.zip -unzip BBC/videos.zip -d BBC -rm -rf BBC/videos.zip -``` - -### AutoShot -Download `AutoShot_test.tar.gz` from [Google drive](https://drive.google.com/file/d/17diRkLlNUUjHDooXdqFUTXYje2-x4Yt6/view?usp=sharing). -``` -tar -zxvf AutoShot.tar.gz -rm AutoShot.tar.gz -``` - -## Evaluation -To evaluate PySceneDetect on a dataset, run the following command from the root of the repo: -``` -python -m benchmark --dataset --detector -``` -For example, to evaluate ContentDetector on the BBC dataset: -``` -python -m benchmark --dataset BBC --detector detect-content -``` -To run all detectors on all datasets: -``` -python -m benchmark --all -``` -The `--all` flag can also be combined with `--dataset` or `--detector`. - -### Result -The performance is computed as recall, precision, f1, and elapsed time. - -#### BBC - -| Detector | Recall | Precision | F1 | Elapsed time (second) | -|:-----------------:|:------:|:---------:|:-----:|:---------------------:| -| AdaptiveDetector | 87.12 | 96.55 | 91.59 | 27.84 | -| ContentDetector | 84.70 | 88.77 | 86.69 | 28.20 | -| HashDetector | 92.30 | 75.56 | 83.10 | 16.00 | -| HistogramDetector | 89.84 | 72.03 | 79.96 | 15.13 | -| ThresholdDetector | 0.00 | 0.00 | 0.00 | 18.95 | - -#### AutoShot - -| Detector | Recall | Precision | F1 | Elapsed time (second) | -|:-----------------:|:------:|:---------:|:-----:|:---------------------:| -| AdaptiveDetector | 70.77 | 77.65 | 74.05 | 1.23 | -| ContentDetector | 63.67 | 76.40 | 69.46 | 1.21 | -| HashDetector | 56.66 | 76.35 | 65.05 | 1.16 | -| HistogramDetector | 63.36 | 53.34 | 57.92 | 1.23 | -| ThresholdDetector | 0.75 | 38.64 | 1.47 | 1.24 | - -## Citation -### BBC -``` -@InProceedings{bbc_dataset, - author = {Lorenzo Baraldi and Costantino Grana and Rita Cucchiara}, - title = {A Deep Siamese Network for Scene Detection in Broadcast Videos}, - booktitle = {Proceedings of the 23rd ACM International Conference on Multimedia}, - year = {2015}, -} -``` - -### AutoShot -``` -@InProceedings{autoshot_dataset, - author = {Wentao Zhu and Yufang Huang and Xiufeng Xie and Wenxian Liu and Jincan Deng and Debing Zhang and Zhangyang Wang and Ji Liu}, - title = {AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary Detection}, - booktitle = {Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR) Workshops}, - year = {2023}, -} -``` +# Benchmarking PySceneDetect + +This page contains the results of benchmarking PySceneDetect's detection accuracy againts public +shot-boundary-detection datasets. Scoring follows the [TRECVID-SBD][trecvid] convention +(greedy 1-to-1 nearest-neighbor matching, with a configurable frame tolerance for hard cuts; +point-in-interval matching for fade transitions; mean absolute frame offset on matched events) +so numbers are comparable to published SBD results. + +[trecvid]: https://www-nlpir.nist.gov/projects/tv2007/pastdata/shot_boundary.07.html + +Supported datasets: + +- [BBC Planet Earth](https://zenodo.org/records/14865504): + 11 long-form broadcast clips; hard cuts only +- [AutoShot](https://drive.google.com/file/d/17diRkLlNUUjHDooXdqFUTXYje2-x4Yt6/view?usp=sharing): + Short-form web clips; hard cuts only +- [ClipShots](https://github.com/Tangshitao/ClipShots): + Short-form web clips; hard cuts and typed gradual transitions (fades/dissolves) + +## Usage + +```bash +# Single detector x single dataset: +python -m benchmark --detector detect-content --dataset BBC +``` + +Pass `--help` for `--dataset-root`, `--backend`, `--tolerance`, and `--out` options. + +### Parameter sweeps + +`python -m benchmark.sweep` runs a grid over detector parameters and reports the +top cells by F1 plus the Pareto front across tolerances. One decode is shared by up to +`--workers` parallel detectors via an internal fan-out wrapper, so the cost scales with +`ceil(cells / workers)` decodes per video rather than `cells` decodes. + +```bash +python -m benchmark.sweep \ + --detector detect-content --dataset BBC \ + --params "threshold=15:35:1;min_scene_len=0.0:1.0:0.1" \ + --tolerance 0,1 --workers 16 \ + --out sweep-content-bbc.json +``` + +`--params`: assignments joined by `;`. Each is either `key=v1,v2,v3` (enumerated values) or +`key=start:stop:step` (numeric range, inclusive when `stop` lands on a step). Omitted keys +use the detector's default. + +Time-valued kwargs (`min_scene_len`, etc.) accept `TimecodeLike` - integers are +frames, floats are seconds, and strings like `"0.1s"` / `"00:00:00.500"` also work. +Prefer floats so the same sweep is comparable across datasets with different +framerates. Use `--quick N` to limit to the first N samples for iteration; published +numbers should always come from the full corpus. + +## Dataset Download + +### BBC + +```bash +# annotations +wget -O BBC/fixed.zip https://zenodo.org/records/14873790/files/fixed.zip +unzip BBC/fixed.zip -d BBC +rm -rf BBC/fixed.zip + +# videos +wget -O BBC/videos.zip https://zenodo.org/records/14873790/files/videos.zip +unzip BBC/videos.zip -d BBC +rm -rf BBC/videos.zip +``` + +### AutoShot + +Download `AutoShot_test.tar.gz` from +[Google Drive](https://drive.google.com/file/d/17diRkLlNUUjHDooXdqFUTXYje2-x4Yt6/view?usp=sharing). + +```bash +tar -zxvf AutoShot_test.tar.gz +rm AutoShot_test.tar.gz +``` + +### ClipShots + +ClipShots is gated behind a dataset request form; direct `wget`-style download links are not +published. See [the download instructions](https://github.com/Tangshitao/ClipShots#downloads) to +obtain the annotations and videos. The expected on-disk layout is: + +``` +ClipShots/ + annotations/{train,test,only_gradual}.json + video_lists/{train,test,only_gradual}.txt + videos/*.mp4 +``` + +The loader defaults to the test split (500 videos). The full corpus is ~46 GB. + +Set `--dataset-root /path/to/datasets` to override. The default dataset location assumes they are +all placed in the benchmark folder (e.g. `benchmark/BBC`, `benchmark/AutoShot`, `benchmark/ClipShots`). + +## Results (defaults) + +Generated by `scripts/benchmark_defaults.sh` at `tolerance=0` (frame-exact matching). +Elapsed is mean wall-clock seconds per video. + +#### BBC + +| Detector | Recall | Precision | F1 | Mean s/video | +|:-----------------:|:------:|:---------:|:-----:|:------------:| +| AdaptiveDetector | 87.12 | 96.55 | 91.59 | 36.12 | +| ContentDetector | 84.70 | 88.77 | 86.69 | 37.02 | +| HashDetector | 92.30 | 75.56 | 83.10 | 25.51 | +| HistogramDetector | 89.84 | 72.03 | 79.96 | 22.29 | +| ThresholdDetector | 0.06 | 0.70 | 0.11 | 16.05 | + +#### AutoShot + +| Detector | Recall | Precision | F1 | Mean s/video | +|:-----------------:|:------:|:---------:|:-----:|:------------:| +| AdaptiveDetector | 70.59 | 77.46 | 73.86 | 3.52 | +| ContentDetector | 63.49 | 76.19 | 69.26 | 4.80 | +| HashDetector | 56.48 | 76.11 | 64.84 | 4.14 | +| HistogramDetector | 63.27 | 53.23 | 57.82 | 3.76 | +| ThresholdDetector | 0.75 | 38.64 | 1.47 | 3.28 | + +#### ClipShots (hard cuts) + +| Detector | Recall | Precision | F1 | Mean s/video | +|:-----------------:|:------:|:---------:|:-----:|:------------:| +| AdaptiveDetector | 85.97 | 41.25 | 55.75 | 1.81 | +| ContentDetector | 81.93 | 42.36 | 55.84 | 2.52 | +| HashDetector | 81.34 | 30.14 | 43.98 | 1.04 | +| HistogramDetector | 72.20 | 11.47 | 19.80 | 0.71 | +| ThresholdDetector | 0.08 | 0.58 | 0.14 | 0.64 | + +#### ClipShots (fades) + +| Detector | Recall | Precision | F1 | +|:-----------------:|:------:|:---------:|:-----:| +| AdaptiveDetector | 13.65 | 98.12 | 23.96 | +| ContentDetector | 26.03 | 98.04 | 41.14 | +| HashDetector | 18.77 | 94.53 | 31.33 | +| HistogramDetector | 69.67 | 81.99 | 75.33 | +| ThresholdDetector | 5.69 | 99.24 | 10.77 | + +## Parameter sweep results + +The tables above use each detector's v0.7 defaults. A grid sweep over the key parameters +scored by hard-cut F1 at 1-frame tolerance, averaged across BBC / AutoShot / ClipShots gives the +best single parameter set for this corpus mix: + +| Detector | Best mean F1 | Best params | v0.7 default | +|:-----------------:|:------------:|:-----------------------------------------------------------|:---------------------------------------| +| ContentDetector | 73.4 | threshold=31, min_scene_len=0.6s | threshold=27 | +| AdaptiveDetector | 76.3 | adaptive_threshold=3.5, window_width=3, min_scene_len=0.6s | adaptive_threshold=3.0, window_width=2 | +| HashDetector | 69.8 | threshold=0.35, size=8 | threshold=0.395, size=16 | +| HistogramDetector | 66.3 | threshold=0.20, bins=128 | threshold=0.05, bins=256 | +| ThresholdDetector | -- | detects fades, not hard cuts (validation only) | threshold=12 | + +Full per-dataset breakdowns are in [`SWEEP_REPORT.md`](SWEEP_REPORT.md), and can be generated +with `python -m benchmark.report_sweep`. The full grids (all detectors and datasets) are driven +by `scripts/benchmark_sweep.sh`. + +## Citations + +### BBC + +``` +@InProceedings{bbc_dataset, + author = {Lorenzo Baraldi and Costantino Grana and Rita Cucchiara}, + title = {A Deep Siamese Network for Scene Detection in Broadcast Videos}, + booktitle = {Proceedings of the 23rd ACM International Conference on Multimedia}, + year = {2015}, +} +``` + +### AutoShot + +``` +@InProceedings{autoshot_dataset, + author = {Wentao Zhu and Yufang Huang and Xiufeng Xie and Wenxian Liu and Jincan Deng and Debing Zhang and Zhangyang Wang and Ji Liu}, + title = {AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary Detection}, + booktitle = {Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR) Workshops}, + year = {2023}, +} +``` + +### ClipShots + +``` +@InProceedings{clipshots_dataset, + author = {Shitao Tang and Litong Feng and Zhanghui Kuang and Yimin Chen and Wei Zhang}, + title = {Fast Video Shot Transition Localization with Deep Structured Models}, + booktitle = {Asian Conference on Computer Vision (ACCV)}, + year = {2018}, +} +``` diff --git a/benchmark/SWEEP_REPORT.md b/benchmark/SWEEP_REPORT.md new file mode 100644 index 00000000..f368072a --- /dev/null +++ b/benchmark/SWEEP_REPORT.md @@ -0,0 +1,107 @@ +# Detector parameter sweep report + +Generated by `benchmark/report_sweep.py` from `benchmark/sweep.py` grid results (hash/hist were swept with `min_scene_len` fixed at its default). F1/precision/recall are percentages on hard cuts; tol = frame tolerance. + +## detect-content + +**Best cell per dataset (by F1@1):** + +| Dataset | F1@1 | Prec@1 | Rec@1 | F1@0 | Params | +| --------- | ----- | ------ | ----- | ----- | ------------------------------- | +| BBC | 88.34 | 90.00 | 86.75 | 87.35 | min_scene_len=0.8, threshold=25 | +| AutoShot | 73.44 | 79.54 | 68.21 | 70.54 | min_scene_len=0.4, threshold=29 | +| ClipShots | 66.74 | 58.93 | 76.95 | 66.44 | min_scene_len=0.8, threshold=35 | + +**Best params averaged across all datasets (mean F1@1):** + +| Mean F1@1 | BBC | AutoShot | ClipShots | Params | +| --------- | ----- | -------- | --------- | ------------------------------- | +| 73.39 | 85.95 | 71.51 | 62.73 | min_scene_len=0.6, threshold=31 | +| 73.37 | 84.27 | 70.95 | 64.89 | min_scene_len=0.6, threshold=33 | +| 73.32 | 83.99 | 72.43 | 63.54 | min_scene_len=0.4, threshold=33 | +| 73.15 | 87.28 | 72.05 | 60.13 | min_scene_len=0.6, threshold=29 | +| 73.09 | 85.18 | 72.97 | 61.10 | min_scene_len=0.4, threshold=31 | + +## detect-adaptive + +**Best cell per dataset (by F1@1):** + +| Dataset | F1@1 | Prec@1 | Rec@1 | F1@0 | Params | +| --------- | ----- | ------ | ----- | ----- | --------------------------------------------------------- | +| BBC | 94.57 | 94.89 | 94.26 | 93.50 | adaptive_threshold=2, min_scene_len=0.6, window_width=2 | +| AutoShot | 77.19 | 80.48 | 74.16 | 75.45 | adaptive_threshold=3.5, min_scene_len=0.4, window_width=3 | +| ClipShots | 65.53 | 60.19 | 71.89 | 65.47 | adaptive_threshold=5.5, min_scene_len=0.6, window_width=3 | + +**Best params averaged across all datasets (mean F1@1):** + +| Mean F1@1 | BBC | AutoShot | ClipShots | Params | +| --------- | ----- | -------- | --------- | --------------------------------------------------------- | +| 76.34 | 90.41 | 76.27 | 62.32 | adaptive_threshold=3.5, min_scene_len=0.6, window_width=3 | +| 76.21 | 87.87 | 76.17 | 64.58 | adaptive_threshold=4, min_scene_len=0.6, window_width=3 | +| 76.18 | 90.43 | 77.19 | 60.93 | adaptive_threshold=3.5, min_scene_len=0.4, window_width=3 | +| 76.16 | 87.91 | 77.07 | 63.51 | adaptive_threshold=4, min_scene_len=0.4, window_width=3 | +| 75.45 | 85.28 | 75.71 | 65.37 | adaptive_threshold=4.5, min_scene_len=0.6, window_width=3 | + +## detect-hash + +**Best cell per dataset (by F1@1):** + +| Dataset | F1@1 | Prec@1 | Rec@1 | F1@0 | Params | +| --------- | ----- | ------ | ----- | ----- | ------------------------ | +| BBC | 86.91 | 81.59 | 92.96 | 85.81 | size=16, threshold=0.425 | +| AutoShot | 70.17 | 76.06 | 65.12 | 66.89 | size=8, threshold=0.325 | +| ClipShots | 56.38 | 44.46 | 77.03 | 55.66 | size=8, threshold=0.4 | + +**Best params averaged across all datasets (mean F1@1):** + +| Mean F1@1 | BBC | AutoShot | ClipShots | Params | +| --------- | ----- | -------- | --------- | ----------------------- | +| 69.83 | 86.38 | 68.98 | 54.12 | size=8, threshold=0.35 | +| 69.63 | 86.65 | 65.86 | 56.38 | size=8, threshold=0.4 | +| 69.63 | 86.65 | 65.86 | 56.37 | size=8, threshold=0.375 | +| 68.18 | 84.28 | 70.17 | 50.10 | size=8, threshold=0.325 | +| 67.00 | 83.71 | 61.66 | 55.65 | size=8, threshold=0.425 | + +## detect-hist + +> Note: thresholds >= 0.21 come from a grid-extension run (`detect-hist-ext-.json`) after the initial grid's best cell landed on its 0.20 upper edge. + +**Best cell per dataset (by F1@1):** + +| Dataset | F1@1 | Prec@1 | Rec@1 | F1@0 | Params | +| --------- | ----- | ------ | ----- | ----- | ------------------------ | +| BBC | 86.58 | 87.32 | 85.86 | 85.42 | bins=128, threshold=0.11 | +| AutoShot | 68.99 | 75.29 | 63.67 | 65.70 | bins=128, threshold=0.2 | +| ClipShots | 53.25 | 46.26 | 62.74 | 52.90 | bins=128, threshold=0.35 | + +**Best params averaged across all datasets (mean F1@1):** + +| Mean F1@1 | BBC | AutoShot | ClipShots | Params | +| --------- | ----- | -------- | --------- | ------------------------ | +| 66.27 | 82.10 | 68.99 | 47.72 | bins=128, threshold=0.2 | +| 66.23 | 81.47 | 68.87 | 48.36 | bins=128, threshold=0.21 | +| 66.20 | 82.74 | 68.81 | 47.06 | bins=128, threshold=0.19 | +| 66.19 | 80.69 | 68.92 | 48.97 | bins=128, threshold=0.22 | +| 66.17 | 79.53 | 68.88 | 50.10 | bins=128, threshold=0.24 | + +## detect-threshold + +> Note: `detect-threshold` detects **fades** (fade to/from black), not hard cuts. These datasets' ground truth is hard cuts, so the hard-cut F1 below is expectedly near zero. It is included to validate the sweep pipeline end-to-end, not as a meaningful hard-cut accuracy result. + +**Best cell per dataset (by F1@1):** + +| Dataset | F1@1 | Prec@1 | Rec@1 | F1@0 | Params | +| --------- | ---- | ------ | ----- | ---- | ------------------------------- | +| BBC | 0.79 | 2.89 | 0.45 | 0.32 | min_scene_len=0.2, threshold=19 | +| AutoShot | 3.98 | 51.09 | 2.07 | 3.14 | min_scene_len=0.4, threshold=20 | +| ClipShots | 1.75 | 6.21 | 1.02 | 0.18 | min_scene_len=0, threshold=10 | + +**Best params averaged across all datasets (mean F1@1):** + +| Mean F1@1 | BBC | AutoShot | ClipShots | Params | +| --------- | ---- | -------- | --------- | ------------------------------- | +| 2.07 | 0.77 | 3.90 | 1.55 | min_scene_len=0, threshold=19 | +| 2.06 | 0.73 | 3.98 | 1.48 | min_scene_len=0, threshold=20 | +| 2.02 | 0.79 | 3.90 | 1.37 | min_scene_len=0.2, threshold=19 | +| 2.00 | 0.71 | 3.98 | 1.30 | min_scene_len=0.2, threshold=20 | +| 1.96 | 0.69 | 3.90 | 1.28 | min_scene_len=0.4, threshold=19 | diff --git a/benchmark/__main__.py b/benchmark/__main__.py index cb92c3ed..2f4f9a3f 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -1,147 +1,180 @@ -import argparse -import os -import time -import typing as ty - -from tqdm import tqdm - -from benchmark.autoshot_dataset import AutoShotDataset -from benchmark.bbc_dataset import BBCDataset -from benchmark.evaluator import Evaluator -from scenedetect import ( - AdaptiveDetector, - ContentDetector, - HashDetector, - HistogramDetector, - ThresholdDetector, - detect, -) - -_DETECTORS = { - "detect-adaptive": AdaptiveDetector, - "detect-content": ContentDetector, - "detect-hash": HashDetector, - "detect-hist": HistogramDetector, - "detect-threshold": ThresholdDetector, -} - - -_DATASETS = { - "BBC": BBCDataset("benchmark/BBC"), - "AutoShot": AutoShotDataset("benchmark/AutoShot"), -} - -_DEFAULT_DETECTOR = "detect-content" -_DEFAULT_DATASET = "BBC" - -_RESULT_PRINT_FORMAT = ( - "Recall: {recall:.2f}, Precision: {precision:.2f}, F1: {f1:.2f} Elapsed time: {elapsed:.2f}\n" -) - - -def _detect_scenes(detector: str, dataset: str, detailed: bool): - pred_scenes = {} - for video_file, scene_file in tqdm(_DATASETS[dataset]): - start = time.time() - pred_scene_list = detect(video_file, _DETECTORS[detector]()) - elapsed = time.time() - start - filename = os.path.basename(video_file) - scenes = { - scene_file: { - "video_file": filename, - "elapsed": elapsed, - "pred_scenes": [scene[1].frame_num for scene in pred_scene_list], - } - } - result = Evaluator().evaluate_performance(scenes) - if detailed: - print(f"\n{filename} results:") - print(_RESULT_PRINT_FORMAT.format(**result) + "\n") - pred_scenes.update(scenes) - - return pred_scenes - - -def run_benchmark(detector: str, dataset: str, detailed: bool): - print(f"Evaluating {detector} on dataset {dataset}...\n") - pred_scenes = _detect_scenes(detector=detector, dataset=dataset, detailed=detailed) - result = Evaluator().evaluate_performance(pred_scenes) - # Print extra separators in detailed output to identify overall results vs individual videos. - if detailed: - print("------------------------------------------------------------") - print(f"\nOverall Results for {detector} on dataset {dataset}:") - print(_RESULT_PRINT_FORMAT.format(**result)) - if detailed: - print("------------------------------------------------------------") - - -def create_parser(): - parser = argparse.ArgumentParser(description="Benchmarking PySceneDetect performance.") - parser.add_argument( - "--dataset", - type=str, - choices=[ - "BBC", - "AutoShot", - ], - help="Dataset name. Supported datasets are BBC and AutoShot.", - ) - parser.add_argument( - "--detector", - type=str, - choices=[ - "detect-adaptive", - "detect-content", - "detect-hash", - "detect-hist", - "detect-threshold", - ], - help="Detector name. Implemented detectors are listed: " - "https://www.scenedetect.com/docs/latest/cli.html", - ) - parser.add_argument( - "--detailed", - action="store_const", - const=True, - help="Print results for each video, in addition to overall summary.", - ) - parser.add_argument( - "--all", - action="store_const", - const=True, - help="Benchmark all detectors on all datasets. If --detector or --dataset are specified, " - "will only run with those.", - ) - return parser - - -def run_all_benchmarks(detector: ty.Optional[str], dataset: ty.Optional[str], detailed: bool): - detectors = {detector: _DETECTORS[detector]} if detector else _DETECTORS - datasets = {dataset: _DATASETS[dataset]} if dataset else _DATASETS - print( - "Running benchmarks for:\n" - f" - Detectors: {', '.join(detectors.keys())}\n" - f" - Datasets: {', '.join(datasets.keys())}\n" - ) - for detector in detectors: - for dataset in datasets: - run_benchmark(detector=detector, dataset=dataset, detailed=detailed) - - -def main(): - parser = create_parser() - args = parser.parse_args() - if args.all: - run_all_benchmarks( - detector=args.detector, dataset=args.dataset, detailed=bool(args.detailed) - ) - else: - run_benchmark( - detector=args.detector if args.detector else _DEFAULT_DETECTOR, - dataset=args.dataset if args.dataset else _DEFAULT_DATASET, - detailed=bool(args.detailed), - ) - - -if __name__ == "__main__": - main() +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Command-line entrypoint for the PySceneDetect benchmark harness. + +Runs one detector against a single dataset using default parameters, and calculates TRECVID-SBD +metrics using the given frame tolerance (usually 0 or 1). Hard-cut precision/recall/F1, mean +absolute frame offset on matches, and per-video elapsed wall-clock are calculated. If a dataset +advertises typed fade ground truth, a second table reports fade precision/recall/F1. +""" + +from __future__ import annotations + +import argparse +import time +from pathlib import Path + +from tqdm import tqdm + +from benchmark._common import ( + DEFAULT_BACKEND, + DETECTORS, + FADE_HEADER, + HARD_HEADER, + fade_row, + hard_row, + parse_tolerances, + render_table, + write_json, +) +from benchmark.dataset import DATASETS, Dataset, resolve_dataset +from benchmark.evaluator import BenchmarkResult, Prediction, evaluate +from scenedetect import AVAILABLE_BACKENDS, detect + + +def _run_predictions( + dataset: Dataset, + detector_name: str, + backend: str, +) -> dict[Path, Prediction]: + """Detect cuts for every video in ``dataset`` and return predictions keyed by path.""" + detector_cls = DETECTORS[detector_name] + predictions: dict[Path, Prediction] = {} + for sample in tqdm(dataset, desc=detector_name): + start = time.time() + pred_scene_list = detect(str(sample.video_file), detector_cls(), backend=backend) + elapsed = time.time() - start + predictions[sample.video_file] = Prediction( + predicted_cuts=[scene[1].frame_num for scene in pred_scene_list], + ground_truth=sample.ground_truth, + elapsed=elapsed, + ) + return predictions + + +def _print_results( + detector: str, + dataset_name: str, + dataset: Dataset, + results: list[BenchmarkResult], +) -> None: + print(f"\n## {detector} on {dataset_name} (hard cuts)\n") + print(render_table(HARD_HEADER, [hard_row(r) for r in results])) + if "fade" in dataset.event_types: + print(f"\n## {detector} on {dataset_name} (fades)\n") + print(render_table(FADE_HEADER, [fade_row(r) for r in results])) + + +# --------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------- # + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Benchmarking PySceneDetect performance.") + parser.add_argument( + "--dataset", + type=str, + required=True, + choices=list(DATASETS.keys()), + help=f"Dataset name. One of: {', '.join(DATASETS.keys())}.", + ) + parser.add_argument( + "--detector", + type=str, + required=True, + choices=list(DETECTORS.keys()), + help=f"Detector name. One of: {', '.join(DETECTORS.keys())}.", + ) + parser.add_argument( + "--dataset-root", + type=str, + default=None, + help=( + "Base directory containing per-dataset subfolders. Defaults to 'benchmark' " + "(the in-repo location). Use this to read videos from an external location, " + "e.g. --dataset-root D:/path/to/benchmark." + ), + ) + parser.add_argument( + "--backend", + type=str, + default=DEFAULT_BACKEND, + choices=sorted(AVAILABLE_BACKENDS.keys()), + help=( + f"Video decoding backend (default: {DEFAULT_BACKEND}). Override to compare " + "detector output across backends, e.g. opencv vs pyav." + ), + ) + parser.add_argument( + "--tolerance", + type=str, + default="0,1", + help=( + "Comma-separated list of frame tolerances for hard-cut matching (default: 0,1). " + "+/-0 is the literature-strict number; +/-1 masks single-frame encoder artifacts." + ), + ) + parser.add_argument( + "--out", + type=str, + default=None, + help="Path to write a machine-readable JSON results file (includes per-video stats).", + ) + parser.add_argument( + "--quick", + type=int, + nargs="?", + const=10, + default=None, + metavar="N", + help=( + "Score only the first N samples from the dataset (default N=10) for fast " + "iteration. Use this to smoke-test config changes; published numbers should " + "always come from the full corpus." + ), + ) + return parser + + +def main() -> None: + args = create_parser().parse_args() + tolerances = parse_tolerances(args.tolerance) + dataset = resolve_dataset(args.dataset, args.dataset_root) + if len(dataset) == 0: + raise SystemExit( + f"Dataset {args.dataset!r} at {args.dataset_root or 'benchmark'} is empty - " + "check that videos and annotations are present." + ) + if args.quick is not None: + dataset._samples = dataset._samples[: args.quick] + print(f"--quick: limited to first {len(dataset)} samples") + print(f"Evaluating {args.detector} on {args.dataset} (backend={args.backend})") + + payloads = _run_predictions(dataset, args.detector, args.backend) + results = [evaluate(payloads, tolerance=t) for t in tolerances] + + _print_results(args.detector, args.dataset, dataset, results) + if args.out: + write_json( + args.out, + { + "detector": args.detector, + "dataset": args.dataset, + "backend": args.backend, + "results": [r.to_dict() for r in results], + }, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmark/_common.py b/benchmark/_common.py new file mode 100644 index 00000000..c175dcc0 --- /dev/null +++ b/benchmark/_common.py @@ -0,0 +1,104 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Shared formatting and detector-registry helpers for ``python -m benchmark`` and +``python -m benchmark.sweep``. + +Kept intentionally small: the two entry points have different prediction loops (one +default-kwargs pass vs a fan-out parameter sweep) but render results into the same +tables. +""" + +from __future__ import annotations + +import json +import math +from typing import Any + +from benchmark.evaluator import BenchmarkResult +from scenedetect import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) + +DEFAULT_BACKEND = "opencv" + +DETECTORS: dict[str, type] = { + "detect-adaptive": AdaptiveDetector, + "detect-content": ContentDetector, + "detect-hash": HashDetector, + "detect-hist": HistogramDetector, + "detect-threshold": ThresholdDetector, +} + + +def parse_tolerances(spec: str) -> tuple[int, ...]: + """Parse ``"0,1,5"`` into ``(0, 1, 5)``. Blank entries (e.g. trailing comma) are dropped.""" + return tuple(int(x.strip()) for x in spec.split(",") if x.strip()) + + +def fmt_pct(value: float, count: int) -> str: + """Percentage, or ``n/a`` when the underlying class has zero events.""" + return "n/a" if count == 0 else f"{value * 100:.2f}" + + +def fmt_offset(value: float) -> str: + return "n/a" if math.isnan(value) else f"{value:.3f}" + + +def render_table(header: list[str], rows: list[list[str]]) -> str: + """Build a pipe-delimited GitHub-flavored Markdown table as a single string.""" + widths = [max(len(header[i]), *(len(r[i]) for r in rows)) for i in range(len(header))] + sep = "| " + " | ".join("-" * w for w in widths) + " |" + header_line = "| " + " | ".join(h.ljust(w) for h, w in zip(header, widths, strict=True)) + " |" + body = [ + "| " + " | ".join(c.ljust(w) for c, w in zip(r, widths, strict=True)) + " |" for r in rows + ] + return "\n".join([header_line, sep, *body]) + + +HARD_HEADER = ["Tolerance", "Precision", "Recall", "F1", "Offset", "Elapsed"] +FADE_HEADER = ["Tolerance", "Precision", "Recall", "F1"] + + +def hard_row(result: BenchmarkResult) -> list[str]: + hard = result.hard_cuts + hard_predictions = hard.matched + hard.false_positives + hard_events = hard.matched + hard.missed + return [ + str(result.tolerance), + fmt_pct(hard.precision, hard_predictions), + fmt_pct(hard.recall, hard_events), + fmt_pct(hard.f1, hard_events), + fmt_offset(result.mean_abs_offset_hard_cuts), + f"{result.elapsed_mean:.2f}", + ] + + +def fade_row(result: BenchmarkResult) -> list[str]: + fades = result.fades + fade_predictions = fades.matched + fades.false_positives + fade_events = fades.matched + fades.missed + return [ + str(result.tolerance), + fmt_pct(fades.precision, fade_predictions), + fmt_pct(fades.recall, fade_events), + fmt_pct(fades.f1, fade_events), + ] + + +def write_json(out_path: str, payload: dict[str, Any]) -> None: + with open(out_path, "w") as f: + json.dump(payload, f, indent=2, default=str) + print(f"\nWrote results to {out_path}") diff --git a/benchmark/analyze_sweep.py b/benchmark/analyze_sweep.py new file mode 100644 index 00000000..01db94f3 --- /dev/null +++ b/benchmark/analyze_sweep.py @@ -0,0 +1,272 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Derive default-parameter recommendations from sweep results. + +Applies a fixed decision procedure to the grid JSONs under ``benchmark/results/sweep/`` +rather than just taking the argmax of mean F1@1: + +1. Baseline the shipped default (nearest grid cell). +2. Candidate set: cells within ``EPSILON`` of the best mean F1@1 (the plateau, not the peak). +3. Neighborhood robustness: reject cells with a steep drop to any one-grid-step neighbor + along a fine-grained numeric axis (categorical axes like ``size``/``bins`` are not + "steps" and are excluded). +4. Weighting sensitivity: a candidate must beat the default under the equal-dataset mean + and under every leave-one-dataset-out mean -- i.e. the *improvement* survives removing + any single dataset. (Being within EPSILON of each scheme's argmax is hopeless when + per-dataset optima diverge, and is not the question a defaults change asks.) The pooled + micro-average is reported for context but is not a gate: pooling events lets the + largest corpus (ClipShots, ~10x the cuts) dominate, making it a dataset-weighting + choice rather than a robustness check. +5. Precision floor: per dataset, candidate precision@1 must not fall more than + ``PRECISION_SLACK`` below the default's. +6. Materiality: recommend a change only for >= ``MIN_GAIN`` mean F1@1 over the default, + gains >= 1.0 on at least two datasets, and no dataset regressing by > 1.0. +7. min_scene_len isolation: for detectors that swept it, also rank with it fixed at the + default-equivalent slice so the threshold recommendation stands on its own. + +Prints a markdown report to stdout. Requires the sweep JSONs locally (not committed); +see ``scripts/benchmark_sweep.sh``. +""" + +from __future__ import annotations + +from benchmark.report_sweep import DATASETS, _load_cells, _params_str, _table + +EPSILON = 1.0 # candidate set: within this many F1 points of the best mean +MAX_NEIGHBOR_DROP = 2.0 # reject cells this much better than their worst neighbor +FINE_AXIS_MIN_VALUES = 4 # axes with fewer distinct values are categorical, not grid steps +PRECISION_SLACK = 5.0 # per-dataset precision@1 may not drop more than this vs default +MIN_GAIN = 2.0 # mean F1@1 gain required to recommend changing a default + +# Shipped defaults mapped onto the swept grid (nearest cell). min_scene_len defaults to +# 15 *frames*; the sweeps used seconds, so 0.6 matches only at 25 fps (BBC) and is ~0.5 +# at 30 fps web video -- flagged in the report. hash's 0.395 maps to the 0.4 grid point. +DEFAULTS: dict[str, dict] = { + "detect-content": {"min_scene_len": 0.6, "threshold": 27}, + "detect-adaptive": {"adaptive_threshold": 3.0, "min_scene_len": 0.6, "window_width": 2}, + "detect-hash": {"size": 16, "threshold": 0.4}, + "detect-hist": {"bins": 256, "threshold": 0.05}, +} +MSL_SWEPT = {"detect-content", "detect-adaptive"} + + +def _f1(matched: int, fp: int, missed: int) -> float: + p = matched / (matched + fp) if matched + fp else 0.0 + r = matched / (matched + missed) if matched + missed else 0.0 + return 200.0 * p * r / (p + r) if p + r else 0.0 + + +class Cell: + """One parameter combination with per-dataset hard-cut results at tolerance 1.""" + + def __init__(self, params: dict, per_ds: dict[str, dict]): + self.params = params + self.key = _params_str(params) + self.per_ds = per_ds # dataset -> hard_cuts dict (matched/fp/missed/precision/recall/f1) + self.mean_f1 = sum(d["f1"] for d in per_ds.values()) / len(per_ds) + self.micro_f1 = _f1( + sum(d["matched"] for d in per_ds.values()), + sum(d["false_positives"] for d in per_ds.values()), + sum(d["missed"] for d in per_ds.values()), + ) + + def lodo(self, skip: str) -> float: + rest = [d["f1"] for ds, d in self.per_ds.items() if ds != skip] + return sum(rest) / len(rest) + + +def _load(det: str) -> list[Cell]: + per_key: dict[str, dict[str, dict]] = {} + params_by_key: dict[str, dict] = {} + for ds in DATASETS: + cells = _load_cells(det, ds) + if cells is None: + return [] + for c in cells: + key = _params_str(c["params"]) + per_key.setdefault(key, {})[ds] = c["results"]["1"]["aggregate"]["hard_cuts"] + params_by_key[key] = c["params"] + return [Cell(params_by_key[k], v) for k, v in per_key.items() if len(v) == len(DATASETS)] + + +def _neighbors(cell: Cell, cells: list[Cell]) -> list[Cell]: + """Cells one grid step away along exactly one fine-grained numeric axis. + + Axes with fewer than ``FINE_AXIS_MIN_VALUES`` distinct values (e.g. ``size=8,16``, + ``bins=128,256``, ``window_width=2,3``) are categorical choices, not grid steps, so + a large score difference across them is not a knife-edge. + """ + axes = {k: sorted({c.params[k] for c in cells}) for k in cell.params} + out = [] + for other in cells: + diff = [k for k in cell.params if other.params[k] != cell.params[k]] + if len(diff) != 1: + continue + (k,) = diff + vals = axes[k] + if len(vals) < FINE_AXIS_MIN_VALUES: + continue + if abs(vals.index(other.params[k]) - vals.index(cell.params[k])) == 1: + out.append(other) + return out + + +def analyze(det: str) -> list[str]: + cells = _load(det) + out = [f"## {det}\n"] + if not cells: + return [*out, "(sweep JSONs missing)\n"] + + # Match by the canonical params string: grid generation leaves float artifacts + # (e.g. 0.4000000000000001) that a plain dict comparison would miss. + default_key = _params_str(DEFAULTS[det]) + default = next((c for c in cells if c.key == default_key), None) + best = max(cells, key=lambda c: c.mean_f1) + + def row(c: Cell, label: str) -> list[str]: + return [ + label, + f"{c.mean_f1:.2f}", + *(f"{c.per_ds[ds]['f1']:.2f}" for ds in DATASETS), + c.key, + ] + + rows = [row(best, "best")] + if default is not None: + rows.insert(0, row(default, "default")) + out.append(_table(["Cell", "Mean F1@1", *DATASETS, "Params"], rows)) + out.append("") + if default is None: + out.append(f"> Default cell {DEFAULTS[det]} not present in the grid; criteria that") + out.append("> compare against the default are skipped below.\n") + + # Gated weighting schemes: equal-dataset mean + leave-one-dataset-out means. A + # candidate is weighting-stable if it beats the default under every scheme, i.e. the + # improvement does not hinge on any single dataset. Micro-average is reported per + # candidate but deliberately not gated (see module docstring). Without a default cell + # to compare against, fall back to within-EPSILON-of-best per scheme. + schemes = [lambda c: c.mean_f1] + schemes += [lambda c, ds=ds: c.lodo(ds) for ds in DATASETS] + scheme_floor = ( + [s(default) for s in schemes] + if default is not None + else [max(s(c) for c in cells) - EPSILON for s in schemes] + ) + + candidates = sorted( + (c for c in cells if c.mean_f1 >= best.mean_f1 - EPSILON), + key=lambda c: c.mean_f1, + reverse=True, + ) + cand_rows = [] + passing = [] + for c in candidates: + nbrs = _neighbors(c, cells) + worst_drop = max((c.mean_f1 - n.mean_f1 for n in nbrs), default=0.0) + robust = worst_drop <= MAX_NEIGHBOR_DROP + stable = all(s(c) >= floor for s, floor in zip(schemes, scheme_floor, strict=True)) + if default is not None: + prec_ok = all( + c.per_ds[ds]["precision"] >= default.per_ds[ds]["precision"] - PRECISION_SLACK + for ds in DATASETS + ) + deltas = [c.per_ds[ds]["f1"] - default.per_ds[ds]["f1"] for ds in DATASETS] + material = ( + c.mean_f1 - default.mean_f1 >= MIN_GAIN + and sum(d >= 1.0 for d in deltas) >= 2 + and all(d >= -1.0 for d in deltas) + ) + else: + prec_ok = material = True + ok = robust and stable and prec_ok and material + if ok: + passing.append(c) + mark = lambda b: "yes" if b else "NO" # noqa: E731 + cand_rows.append( + [ + f"{c.mean_f1:.2f}", + f"{c.micro_f1:.2f}", + f"{worst_drop:.2f}", + mark(robust), + mark(stable), + mark(prec_ok), + mark(material), + "PASS" if ok else "-", + c.key, + ] + ) + out.append(f"**Candidates (mean F1@1 within {EPSILON:g} of best):**\n") + out.append( + _table( + [ + "Mean", + "Micro", + "NbrDrop", + "Robust", + "WeightStable", + "PrecFloor", + "Material", + "Verdict", + "Params", + ], + cand_rows, + ) + ) + out.append("") + + if det in MSL_SWEPT: + msl = DEFAULTS[det]["min_scene_len"] + fixed = [c for c in cells if c.params.get("min_scene_len") == msl] + top = sorted(fixed, key=lambda c: c.mean_f1, reverse=True)[:3] + out.append(f"**With min_scene_len fixed at the default-equivalent {msl:g}s:**\n") + out.append( + _table( + ["Mean F1@1", *DATASETS, "Params"], + [ + [f"{c.mean_f1:.2f}", *(f"{c.per_ds[ds]['f1']:.2f}" for ds in DATASETS), c.key] + for c in top + ], + ) + ) + out.append("") + + if passing: + pick = passing[0] + gain = f" (+{pick.mean_f1 - default.mean_f1:.2f} mean F1@1 vs default)" if default else "" + out.append(f"**Recommendation: CHANGE to `{pick.key}`{gain}.**\n") + else: + out.append( + "**Recommendation: KEEP current default** (no candidate passes all criteria; " + "see verdict column for which gate fails).\n" + ) + return out + + +def main() -> None: + print("# Detector default recommendations from sweep data\n") + print( + "Generated by `benchmark/analyze_sweep.py`. Criteria: candidate plateau within " + f"{EPSILON:g} F1 of best; worst one-step neighbor drop <= {MAX_NEIGHBOR_DROP:g} along " + "fine-grained axes; beats the default under equal-mean and each leave-one-dataset-out " + "weighting (pooled micro-average reported, not gated); " + f"per-dataset precision floor (default - " + f"{PRECISION_SLACK:g}); materiality (>= {MIN_GAIN:g} mean F1@1 gain, >= 1.0 on two " + "datasets, no dataset worse by > 1.0). min_scene_len defaults to 15 frames (= 0.6s " + "only at 25 fps); the default cell uses the nearest swept slice.\n" + ) + for det in DEFAULTS: + for line in analyze(det): + print(line) + + +if __name__ == "__main__": + main() diff --git a/benchmark/autoshot_dataset.py b/benchmark/autoshot_dataset.py deleted file mode 100644 index 80a58da1..00000000 --- a/benchmark/autoshot_dataset.py +++ /dev/null @@ -1,31 +0,0 @@ -import glob -import os - - -class AutoShotDataset: - """ - The AutoShot Dataset (test splits) proposed by Zhu et al. in AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary Detection - Link: https://openaccess.thecvf.com/content/CVPR2023W/NAS/html/Zhu_AutoShot_A_Short_Video_Dataset_and_State-of-the-Art_Shot_Boundary_Detection_CVPRW_2023_paper.html - The original test set consists of 200 videos, but 36 videos are missing (AutoShot/videos/.mp4). - The annotated scenes are provided in corresponding files (AutoShot/annotations/.txt) - """ - - def __init__(self, dataset_dir: str): - self._video_files = [ - file for file in sorted(glob.glob(os.path.join(dataset_dir, "videos", "*.mp4"))) - ] - self._scene_files = [ - file for file in sorted(glob.glob(os.path.join(dataset_dir, "annotations", "*.txt"))) - ] - for video_file, scene_file in zip(self._video_files, self._scene_files, strict=True): - video_id = os.path.basename(video_file).split(".")[0] - scene_id = os.path.basename(scene_file).split(".")[0] - assert video_id == scene_id - - def __getitem__(self, index): - video_file = self._video_files[index] - scene_file = self._scene_files[index] - return video_file, scene_file - - def __len__(self): - return len(self._video_files) diff --git a/benchmark/bbc_dataset.py b/benchmark/bbc_dataset.py deleted file mode 100644 index 5feb54ae..00000000 --- a/benchmark/bbc_dataset.py +++ /dev/null @@ -1,32 +0,0 @@ -import glob -import os - - -class BBCDataset: - """ - The BBC Dataset, proposed by Baraldi et al. in A deep siamese network for scene detection in broadcast videos - Link: https://arxiv.org/abs/1510.08893 - The dataset consists of 11 videos (BBC/videos/bbc_01.mp4 to BBC/videos/bbc_11.mp4). - The annotated scenes are provided in corresponding files (BBC/fixed/[i]-scenes.txt). - """ - - def __init__(self, dataset_dir: str): - self._video_files = [ - file for file in sorted(glob.glob(os.path.join(dataset_dir, "videos", "*.mp4"))) - ] - self._scene_files = [ - file for file in sorted(glob.glob(os.path.join(dataset_dir, "fixed", "*.txt"))) - ] - assert len(self._video_files) == len(self._scene_files) - for video_file, scene_file in zip(self._video_files, self._scene_files, strict=True): - video_id = os.path.basename(video_file).replace("bbc_", "").split(".")[0] - scene_id = os.path.basename(scene_file).split("-")[0] - assert video_id == scene_id - - def __getitem__(self, index): - video_file = self._video_files[index] - scene_file = self._scene_files[index] - return video_file, scene_file - - def __len__(self): - return len(self._video_files) diff --git a/benchmark/dataset.py b/benchmark/dataset.py new file mode 100644 index 00000000..e0a73a91 --- /dev/null +++ b/benchmark/dataset.py @@ -0,0 +1,240 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Benchmark dataset definitions and registry. + +Each :class:`Dataset` is a corpus of :class:`Sample` records (video file + typed ground truth) +loaded eagerly at construction. Ground-truth files for the supported corpora are at most a few +hundred kilobytes total, so eager loading avoids re-reading the same files for every sweep cell. + +Add a new dataset by: + +1. Subclassing :class:`Dataset` and populating ``self._samples`` in ``__init__``. +2. Registering it in :data:`DATASETS` under the name used by ``--dataset``. +""" + +from __future__ import annotations + +import glob +import json +import logging +import os +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +from benchmark.evaluator import EventInterval, Frames, GroundTruth + +logger = logging.getLogger("pyscenedetect") + + +@dataclass(frozen=True) +class Sample: + """One scored video: a path on disk plus its typed ground truth.""" + + video_file: Path + ground_truth: GroundTruth + + +class Dataset: + """Iterable corpus of :class:`Sample` records. + + Subclasses populate ``self._samples`` in their constructor; this base provides the iteration + and length protocol. ``event_types`` advertises which TRECVID-SBD event categories the + dataset's ground truth contains, so consumers can skip columns/tables for categories that + have no events (e.g. fade transitions on BBC/AutoShot). + """ + + event_types: frozenset[str] = frozenset({"hard_cut"}) + _samples: list[Sample] + + def __iter__(self) -> Iterator[Sample]: + return iter(self._samples) + + def __len__(self) -> int: + return len(self._samples) + + +def _read_tab_separated_cuts(scene_file: str) -> list[Frames]: + """Parse a BBC/AutoShot-style annotation file. + + Each line is tab-separated; the second column is the 0-based frame index of a + hard cut. Returns 1-based frame indices, matching the convention used by + :class:`scenedetect.FrameTimecode`. + """ + with open(scene_file) as f: + return [int(line.strip().split("\t")[1]) + 1 for line in f] + + +class BBCDataset(Dataset): + """The BBC Planet Earth dataset. + + Baraldi et al., "A Deep Siamese Network for Scene Detection in Broadcast Videos", + ACM Multimedia 2015. https://arxiv.org/abs/1510.08893 + + 11 long-form videos (``BBC/videos/bbc_.mp4``) with hard-cut annotations in + ``BBC/fixed/-scenes.txt``. + """ + + def __init__(self, dataset_dir: str): + video_files = sorted(glob.glob(os.path.join(dataset_dir, "videos", "*.mp4"))) + scene_files = sorted(glob.glob(os.path.join(dataset_dir, "fixed", "*.txt"))) + if len(video_files) != len(scene_files): + raise ValueError( + f"BBC dataset at {dataset_dir!r}: {len(video_files)} videos but " + f"{len(scene_files)} annotation files." + ) + self._samples: list[Sample] = [] + for video_file, scene_file in zip(video_files, scene_files, strict=True): + video_id = os.path.basename(video_file).replace("bbc_", "").split(".")[0] + scene_id = os.path.basename(scene_file).split("-")[0] + if video_id != scene_id: + raise ValueError(f"BBC id mismatch: {video_file} vs {scene_file}") + self._samples.append( + Sample( + video_file=Path(video_file), + ground_truth=GroundTruth(hard_cuts=_read_tab_separated_cuts(scene_file)), + ) + ) + + +class AutoShotDataset(Dataset): + """The AutoShot dataset (test splits). + + Zhu et al., "AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary + Detection", CVPRW 2023. The original test set has 200 videos; 36 are no longer + publicly available, so the corpus iterates over whatever is present on disk. + + Videos at ``AutoShot/videos/.mp4``, hard-cut annotations at + ``AutoShot/annotations/.txt``. + """ + + def __init__(self, dataset_dir: str): + # 36 of the original 200 videos are no longer publicly available, so intersect + # by id rather than zipping the directory listings strictly. + videos_by_id = { + os.path.basename(p).split(".")[0]: p + for p in glob.glob(os.path.join(dataset_dir, "videos", "*.mp4")) + } + scenes_by_id = { + os.path.basename(p).split(".")[0]: p + for p in glob.glob(os.path.join(dataset_dir, "annotations", "*.txt")) + } + self._samples: list[Sample] = [ + Sample( + video_file=Path(videos_by_id[vid]), + ground_truth=GroundTruth(hard_cuts=_read_tab_separated_cuts(scenes_by_id[vid])), + ) + for vid in sorted(videos_by_id.keys() & scenes_by_id.keys()) + ] + + +class ClipShotsDataset(Dataset): + """The ClipShots dataset (test split by default). + + Tang et al., "Fast Video Shot Transition Localization with Deep Structured Models", + ACCV 2018. https://github.com/Tangshitao/ClipShots + + The only in-tree dataset with typed gradual-transition (fade/dissolve) ground truth in + addition to hard cuts. Layout under ``ClipShots/``:: + + annotations/{train,test,only_gradual}.json + video_lists/{train,test,only_gradual}.txt (optional split filter) + videos/*.mp4 + + Each annotation entry is ``{"transitions": [[start, end], ...], "frame_num": float}``. + Hard cuts are single-frame spans (``end == start + 1``); wider spans are gradual + transitions. Unlike the BBC/AutoShot annotations, ClipShots frame indices already match + PySceneDetect's boundary-frame convention (the prediction's ``frame_num`` lines up with + ``transition[1]`` directly), so no offset is applied here. + + Loading rules: + + - Videos listed in ``video_lists/.txt`` but absent from the annotations JSON are + silently ignored (the filter runs against the JSON, not the other way). + - Annotations whose ``.mp4`` is not on disk are skipped (so partial corpora work). + - Malformed transitions (fewer than 2 entries, negative span, zero-width span) are + skipped with a warning rather than crashing the load. + + Only the ``ClipShotsDataset(dir, split=...)`` constructor honors a non-default split; + the registry entry in :data:`DATASETS` always loads the ``test`` split. + """ + + event_types = frozenset({"hard_cut", "fade"}) + + def __init__(self, dataset_dir: str, split: str = "test"): + ann_path = os.path.join(dataset_dir, "annotations", f"{split}.json") + videos_dir = os.path.join(dataset_dir, "videos") + with open(ann_path) as f: + annotations: dict = json.load(f) + split_list_path = os.path.join(dataset_dir, "video_lists", f"{split}.txt") + if os.path.exists(split_list_path): + with open(split_list_path) as allow_f: + allowed = {line.strip() for line in allow_f if line.strip()} + annotations = {k: v for k, v in annotations.items() if k in allowed} + total = len(annotations) + skipped_missing = 0 + self._samples: list[Sample] = [] + for video_name in sorted(annotations): + video_path = os.path.join(videos_dir, video_name) + if not os.path.exists(video_path): + skipped_missing += 1 + continue + hard_cuts: list[Frames] = [] + fades: list[EventInterval] = [] + # `... or []` (not `.get(k, [])`) so an explicit JSON `null` is treated as empty. + for transition in annotations[video_name].get("transitions") or []: + if len(transition) < 2: + logger.warning("ClipShots %s: malformed transition %r", video_name, transition) + continue + start, end = int(transition[0]), int(transition[1]) + span = end - start + if span == 1: + hard_cuts.append(end) + elif span > 1: + fades.append(EventInterval(start=start, end=end)) + else: + logger.warning( + "ClipShots %s: skipping degenerate transition %r", video_name, transition + ) + self._samples.append( + Sample( + video_file=Path(video_path), + ground_truth=GroundTruth(hard_cuts=hard_cuts, fades=fades), + ) + ) + logger.info( + "ClipShots %s: loaded %d/%d samples (%d videos missing on disk)", + split, + len(self._samples), + total, + skipped_missing, + ) + + +# Mapping of --dataset names to constructors. Typed as a plain callable so +# subclass-specific positional signatures (each takes ``dataset_dir: str``) +# aren't widened away by the base ``Dataset`` class's empty ``__init__``. +DATASETS: dict[str, type] = { + "BBC": BBCDataset, + "AutoShot": AutoShotDataset, + "ClipShots": ClipShotsDataset, +} + + +def resolve_dataset(name: str, root: str | None) -> Dataset: + """Instantiate the named dataset. + + ``root`` overrides the default repo-relative path; pass ``None`` (or the empty string) + to use ``benchmark//``. + """ + base = root if root else "benchmark" + return DATASETS[name](os.path.join(base, name)) diff --git a/benchmark/evaluator.py b/benchmark/evaluator.py index ee8801ac..07a666b6 100644 --- a/benchmark/evaluator.py +++ b/benchmark/evaluator.py @@ -1,37 +1,346 @@ -from statistics import mean - - -class Evaluator: - def __init__(self): - pass - - def _load_scenes(self, scene_filename): - with open(scene_filename) as f: - gt_scene_list = [x.strip().split("\t")[1] for x in f.readlines()] - gt_scene_list = [int(x) + 1 for x in gt_scene_list] - return gt_scene_list - - def evaluate_performance(self, pred_scenes): - total_correct = 0 - total_pred = 0 - total_gt = 0 - assert pred_scenes - - for scene_file, pred in pred_scenes.items(): - gt_scene_list = self._load_scenes(scene_file) - pred_list = pred["pred_scenes"] - total_correct += len(set(pred_list) & set(gt_scene_list)) - total_pred += len(pred_list) - total_gt += len(gt_scene_list) - - recall = total_correct / total_gt - precision = total_correct / total_pred if total_pred != 0 else 0 - f1 = 2 * recall * precision / (recall + precision) if (recall + precision) != 0 else 0 - avg_elapsed = mean([x["elapsed"] for x in pred_scenes.values()]) - result = { - "recall": recall * 100, - "precision": precision * 100, - "f1": f1 * 100, - "elapsed": avg_elapsed, - } - return result +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Scoring for shot-boundary-detection benchmarks. + +Implements the TRECVID-SBD evaluation convention. Each predicted boundary is one integer frame +number. Hard cuts are matched against ground-truth frames, with a configurable frame-tolerance. +Matches are scored via greedy 1-to-1 nearest-neighbor assignment. Fades and other gradual +transitions are matched by point-in-interval membership, where the prediction inside an interval +is considered a match. Other predictions in the same interval are considered false positives. + +References: +- Smeaton, Over & Doherty (2010), "Video shot boundary detection: Seven years of TRECVid activity", + *Computer Vision and Image Understanding*. + https://ora.ox.ac.uk/objects/uuid:868aebdf-298a-4567-b47f-c8f9e3a6ac7a +- Hassanien et al. (2017), "Large-scale, Fast and Accurate Shot Boundary Detection through + Spatio-temporal Convolutional Neural Networks", arXiv:1705.03281. + https://arxiv.org/abs/1705.03281 +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from statistics import mean +from typing import TypeAlias + +# 1-based frame number, matching the convention used by the BBC/AutoShot text annotations and by +# PySceneDetect's :class:`FrameTimecode`. Used for cut positions and for tolerance windows. +# +# Ironically, all the work we did in v0.7 to support VFR is meaningless for most existing benchmarks +# since they are all CFR. In the future we should consider extending the API to support temporal +# units of time or PTS, and also see if other datasets might take this into account. +Frames: TypeAlias = int + + +@dataclass(frozen=True) +class EventInterval: + """Inclusive ``[start, end]`` frame range for a gradual transition (dissolve/fade).""" + + start: Frames + end: Frames + + def contains(self, frame: Frames) -> bool: + return self.start <= frame <= self.end + + +@dataclass +class GroundTruth: + """Ground truth for one video, consisting of hard cut frames and fade intervals.""" + + hard_cuts: list[Frames] + fades: list[EventInterval] = field(default_factory=list) + category: str | None = None + + +@dataclass +class Prediction: + """One detector run on one video, ready for scoring against typed ground truth.""" + + predicted_cuts: list[Frames] + """Flat list of predicted hard cut frame numbers, 1-based.""" + ground_truth: GroundTruth + """Ground truth for the video being scored.""" + elapsed: float + """How long it took to run the prediction, in seconds. Used for performance not accuracy.""" + + +@dataclass +class EventMetrics: + """Per-event-type scoring counts used to calculate precision, recall, and F1 score. + + Each instance should be used to score *one* event type (either hard cuts *or* fade transitions) + against ground truth. + """ + + # Detector fired on a real event in the ground truth. + matched: int = 0 + # Detector fired but there was no real event at that frame. + false_positives: int = 0 + # Real event in the ground truth that the detector failed to fire on. + missed: int = 0 + + @property + def precision(self) -> float: + denom = self.matched + self.false_positives + return self.matched / denom if denom else 0.0 + + @property + def recall(self) -> float: + denom = self.matched + self.missed + return self.matched / denom if denom else 0.0 + + @property + def f1(self) -> float: + p, r = self.precision, self.recall + return 2 * p * r / (p + r) if (p + r) else 0.0 + + def __add__(self, other: EventMetrics) -> EventMetrics: + return EventMetrics( + matched=self.matched + other.matched, + false_positives=self.false_positives + other.false_positives, + missed=self.missed + other.missed, + ) + + def to_dict(self) -> dict: + return { + "matched": self.matched, + "false_positives": self.false_positives, + "missed": self.missed, + "precision": round(self.precision * 100, 4), + "recall": round(self.recall * 100, 4), + "f1": round(self.f1 * 100, 4), + } + + +@dataclass +class VideoMetrics: + """Per-video result at one tolerance. The video's path lives in the enclosing + :class:`BenchmarkResult.per_video` dict key, not on this object.""" + + elapsed: float + category: str | None + hard_cuts: EventMetrics + fades: EventMetrics + # (sum of |prediction - ground_truth|, match count) over hard-cut matches. + # Stored as raw sums so aggregation across videos is `sum / total_matched`, + # not a mean-of-means. + hard_offset: tuple[float, int] + + @property + def mean_abs_offset(self) -> float: + s, n = self.hard_offset + return s / n if n else math.nan + + def to_dict(self) -> dict: + return { + "elapsed": self.elapsed, + "category": self.category, + "hard_cuts": self.hard_cuts.to_dict(), + "fades": self.fades.to_dict(), + "mean_abs_offset_hard_cuts": self.mean_abs_offset, + } + + +@dataclass +class BenchmarkResult: + """Aggregate result of running one detector configuration on a dataset at one tolerance. + + ``per_video`` is keyed by source video path so per-video lookups are explicit; aggregate + properties sum counts across all videos (same convention used by TRECVID). + """ + + per_video: dict[Path, VideoMetrics] + tolerance: Frames + + @property + def hard_cuts(self) -> EventMetrics: + total = EventMetrics() + for v in self.per_video.values(): + total = total + v.hard_cuts + return total + + @property + def fades(self) -> EventMetrics: + total = EventMetrics() + for v in self.per_video.values(): + total = total + v.fades + return total + + @property + def mean_abs_offset_hard_cuts(self) -> float: + num = sum(v.hard_offset[0] for v in self.per_video.values()) + den = sum(v.hard_offset[1] for v in self.per_video.values()) + return num / den if den else math.nan + + @property + def elapsed_total(self) -> float: + return sum(v.elapsed for v in self.per_video.values()) + + @property + def elapsed_mean(self) -> float: + return mean(v.elapsed for v in self.per_video.values()) if self.per_video else 0.0 + + def by_category(self) -> dict[str, BenchmarkResult]: + buckets: dict[str, dict[Path, VideoMetrics]] = {} + for path, v in self.per_video.items(): + buckets.setdefault(v.category or "unknown", {})[path] = v + return { + g: BenchmarkResult(per_video=vids, tolerance=self.tolerance) + for g, vids in buckets.items() + } + + def to_dict(self, root: Path | None = None) -> dict: + def _fmt_path(p: Path) -> str: + if root is not None: + try: + return p.relative_to(root).as_posix() + except ValueError: + pass + return p.as_posix() + + return { + "tolerance": self.tolerance, + "aggregate": { + "hard_cuts": self.hard_cuts.to_dict(), + "mean_abs_offset_hard_cuts": self.mean_abs_offset_hard_cuts, + "fades": self.fades.to_dict(), + "elapsed_total": self.elapsed_total, + "elapsed_mean": self.elapsed_mean, + "video_count": len(self.per_video), + }, + "per_video": {_fmt_path(path): v.to_dict() for path, v in self.per_video.items()}, + } + + +def _score_hard_cuts( + predicted_cuts: Iterable[Frames], + ground_truth_cuts: Iterable[Frames], + tolerance: Frames, +) -> tuple[EventMetrics, list[Frames]]: + """Greedy 1-to-1 nearest-neighbor matching within ``tolerance`` frames. + + Builds the set of all (prediction, ground-truth) candidate pairs whose absolute frame distance + is within tolerance, sorts by distance, and walks the sorted list claiming the first unused + pair each time. Ties on distance are broken by stable iteration order, which is deterministic + but otherwise unspecified - fine since we report aggregate metrics, not per-event assignments. + + Returns the event metrics and the per-match absolute offsets (for later averaging). + """ + predicted_cuts = list(predicted_cuts) + ground_truth_cuts = list(ground_truth_cuts) + candidates: list[tuple[int, int, int]] = [] + for i, p in enumerate(predicted_cuts): + for j, g in enumerate(ground_truth_cuts): + d = abs(p - g) + if d <= tolerance: + candidates.append((d, i, j)) + candidates.sort() + prediction_used = [False] * len(predicted_cuts) + ground_truth_used = [False] * len(ground_truth_cuts) + offsets: list[int] = [] + for d, i, j in candidates: + if not prediction_used[i] and not ground_truth_used[j]: + prediction_used[i] = True + ground_truth_used[j] = True + offsets.append(d) + matched = len(offsets) + return ( + EventMetrics( + matched=matched, + false_positives=len(predicted_cuts) - matched, + missed=len(ground_truth_cuts) - matched, + ), + offsets, + ) + + +def _score_fade_transitions( + predicted_cuts: Iterable[Frames], + intervals: Iterable[EventInterval], +) -> tuple[EventMetrics, set[int]]: + """Point-in-interval matching for gradual fade transitions. + + Each prediction that falls inside any ground-truth interval is consumed by that interval + (first-match wins). The first prediction to land in an interval is the match; any further + predictions in the same interval are false positives. Predictions outside every interval are + not touched here - they go back to the hard-cut scorer. + + Returns the fade transition metrics and the set of *positional indices* (into + ``predicted_cuts``, not frame values) that were consumed by a fade interval, so the caller + can skip them when running hard matching. + """ + predicted_cuts = list(predicted_cuts) + intervals = list(intervals) + consumed: set[int] = set() + intervals_matched: set[EventInterval] = set() + matched = 0 + false_positives = 0 + for k, p in enumerate(predicted_cuts): + for interval in intervals: + if interval.contains(p): + consumed.add(k) + if interval in intervals_matched: + false_positives += 1 + else: + intervals_matched.add(interval) + matched += 1 + break + missed = len(intervals) - matched + return ( + EventMetrics(matched=matched, false_positives=false_positives, missed=missed), + consumed, + ) + + +def score_video( + predicted_cuts: Iterable[Frames], + ground_truth: GroundTruth, + tolerance: Frames, + elapsed: float, +) -> VideoMetrics: + """Score one video against typed ground truth at one tolerance. + + Fade transition matching runs first; predictions that land inside any fade interval are + consumed there and excluded from hard-cut matching. The remaining predictions are matched + against ground-truth hard cuts at ``tolerance`` frames. + """ + predicted_cuts = list(predicted_cuts) + + fade_metrics, consumed = _score_fade_transitions(predicted_cuts, ground_truth.fades) + remaining_cuts = [p for k, p in enumerate(predicted_cuts) if k not in consumed] + hard_metrics, offsets = _score_hard_cuts(remaining_cuts, ground_truth.hard_cuts, tolerance) + + return VideoMetrics( + elapsed=elapsed, + category=ground_truth.category, + hard_cuts=hard_metrics, + fades=fade_metrics, + hard_offset=(float(sum(offsets)), len(offsets)), + ) + + +def evaluate(predictions: dict[Path, Prediction], tolerance: Frames) -> BenchmarkResult: + """Score predictions at a single tolerance and return aggregate + per-video results.""" + assert predictions, "predictions must not be empty" + videos = { + path: score_video( + predicted_cuts=p.predicted_cuts, + ground_truth=p.ground_truth, + tolerance=tolerance, + elapsed=p.elapsed, + ) + for path, p in predictions.items() + } + return BenchmarkResult(per_video=videos, tolerance=tolerance) diff --git a/benchmark/report_sweep.py b/benchmark/report_sweep.py new file mode 100644 index 00000000..854049fa --- /dev/null +++ b/benchmark/report_sweep.py @@ -0,0 +1,149 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Consolidate per-(detector, dataset) sweep JSONs into a single markdown report. + +Reads ``benchmark/results/sweep/-.json`` for all five detectors and writes +``benchmark/SWEEP_REPORT.md``: the best cell by F1@1 per (detector, dataset), the top-5 +per cell, and the cell that is best *on average* across datasets per detector (a single +recommended default). + +All results come from the decode-based sweep (``benchmark/sweep.py``, driven by +``scripts/benchmark_sweep.sh``). Unlike content/adaptive, hash/hist were swept with the default +``min_scene_len`` fixed. If a ``-ext-.json`` grid-extension file exists +alongside the main JSON, its cells are merged in. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +DETECTORS = [ + "detect-content", + "detect-adaptive", + "detect-hash", + "detect-hist", + "detect-threshold", +] +DATASETS = ["BBC", "AutoShot", "ClipShots"] +SWEEP_DIR = Path("benchmark/results/sweep") +OUT = Path("benchmark/SWEEP_REPORT.md") + + +def _hard(cell: dict, tol: str) -> dict: + return cell["results"][tol]["aggregate"]["hard_cuts"] + + +def _params_str(p: dict) -> str: + # :g strips float artifacts from grid generation (e.g. 0.42500000000000016 -> 0.425). + def fmt(v): + return f"{v:g}" if isinstance(v, float) else str(v) + + return ", ".join(f"{k}={fmt(v)}" for k, v in sorted(p.items())) + + +def _table(header: list[str], rows: list[list[str]]) -> str: + widths = [max(len(header[i]), *(len(r[i]) for r in rows)) for i in range(len(header))] + + def line(cells): + return "| " + " | ".join(c.ljust(w) for c, w in zip(cells, widths, strict=True)) + " |" + + sep = "| " + " | ".join("-" * w for w in widths) + " |" + return "\n".join([line(header), sep, *(line(r) for r in rows)]) + + +def _load_cells(det: str, ds: str) -> list[dict] | None: + path = SWEEP_DIR / f"{det}-{ds}.json" + if not path.exists(): + return None + cells = json.loads(path.read_text())["cells"] + ext = SWEEP_DIR / f"{det}-ext-{ds}.json" + if ext.exists(): + cells = cells + json.loads(ext.read_text())["cells"] + return cells + + +def main() -> None: + out: list[str] = ["# Detector parameter sweep report", ""] + out.append( + "Generated by `benchmark/report_sweep.py` from `benchmark/sweep.py` grid results " + "(hash/hist were swept with `min_scene_len` fixed at its default). " + "F1/precision/recall are percentages on hard cuts; tol = frame tolerance.\n" + ) + + for det in DETECTORS: + out.append(f"## {det}\n") + if det == "detect-threshold": + out.append( + "> Note: `detect-threshold` detects **fades** (fade to/from black), not hard " + "cuts. These datasets' ground truth is hard cuts, so the hard-cut F1 below is " + "expectedly near zero. It is included to validate the sweep pipeline end-to-end, " + "not as a meaningful hard-cut accuracy result.\n" + ) + if det == "detect-hist": + out.append( + "> Note: thresholds >= 0.21 come from a grid-extension run " + "(`detect-hist-ext-.json`) after the initial grid's best cell landed " + "on its 0.20 upper edge.\n" + ) + # Best-per-dataset summary. + summary_rows = [] + # Track each cell's F1@1 across datasets for an averaged recommendation. + per_cell_f1: dict[str, list[float]] = {} + per_cell_params: dict[str, dict] = {} + for ds in DATASETS: + cells = _load_cells(det, ds) + if cells is None: + summary_rows.append([ds, "(missing)", "", "", "", ""]) + continue + best = max(cells, key=lambda c: _hard(c, "1")["f1"]) + h1, h0 = _hard(best, "1"), _hard(best, "0") + summary_rows.append( + [ + ds, + f"{h1['f1']:.2f}", + f"{h1['precision']:.2f}", + f"{h1['recall']:.2f}", + f"{h0['f1']:.2f}", + _params_str(best["params"]), + ] + ) + for c in cells: + key = _params_str(c["params"]) + per_cell_f1.setdefault(key, []).append(_hard(c, "1")["f1"]) + per_cell_params[key] = c["params"] + out.append("**Best cell per dataset (by F1@1):**\n") + out.append( + _table( + ["Dataset", "F1@1", "Prec@1", "Rec@1", "F1@0", "Params"], + summary_rows, + ) + ) + out.append("") + # Averaged recommendation: cells scored on all datasets, ranked by mean F1@1. + full = {k: v for k, v in per_cell_f1.items() if len(v) == len(DATASETS)} + if full: + ranked = sorted(full.items(), key=lambda kv: sum(kv[1]) / len(kv[1]), reverse=True) + rec_rows = [ + [f"{sum(v) / len(v):.2f}", *(f"{x:.2f}" for x in v), k] for k, v in ranked[:5] + ] + out.append("**Best params averaged across all datasets (mean F1@1):**\n") + out.append(_table(["Mean F1@1", *DATASETS, "Params"], rec_rows)) + out.append("") + + OUT.write_text("\n".join(out), encoding="utf-8") + print(f"Wrote {OUT}") + print("\n".join(out)) + + +if __name__ == "__main__": + main() diff --git a/benchmark/sweep.py b/benchmark/sweep.py new file mode 100644 index 00000000..dbd20d78 --- /dev/null +++ b/benchmark/sweep.py @@ -0,0 +1,452 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Parameter sweep harness for one detector on one dataset. + +Brute-force grid search over a Cartesian product of detector parameters. The cost of +the grid is amortized using :class:`FanOutVideoStream`. One video decode per chunk of +``--workers`` cells, so a 100-cell grid on a 500-video corpus costs roughly +``500 * ceil(100 / workers)`` decodes, not ``500 * 100``. + +Use ``--params "key=v1,v2,v3"`` for enumerated values and ``"key=a:b:s"`` for a numeric +``[a, b]`` range with step ``s`` (inclusive of ``b`` when the step lands there). +Multiple keys are separated by ``;`` and form a Cartesian product. + +Example:: + + python -m benchmark.sweep \\ + --detector detect-content --dataset BBC \\ + --params "threshold=15:35:1;min_scene_len=0.0:1.0:0.1" \\ + --tolerance 0,1 --workers 16 --out sweep.json + +``min_scene_len`` is a :data:`TimecodeLike`: integers are frames, floats are seconds, +strings like ``"0.1s"`` or ``"00:00:00.500"`` also work. Prefer floats so the same +sweep is meaningful across datasets with different framerates. + +Reports the top-10 cells by F1 at each tolerance plus the Pareto front across the two +tolerances. The full grid lives in the JSON output for offline plotting. +""" + +from __future__ import annotations + +import argparse +import itertools +import threading +import time +from pathlib import Path +from typing import Any + +from tqdm import tqdm + +from benchmark._common import ( + DEFAULT_BACKEND, + DETECTORS, + parse_tolerances, + render_table, + write_json, +) +from benchmark.dataset import DATASETS, Dataset, resolve_dataset +from benchmark.evaluator import BenchmarkResult, Prediction, evaluate +from scenedetect import AVAILABLE_BACKENDS, SceneManager, open_video +from scenedetect._fan_out import FanOutVideoStream + +# --------------------------------------------------------------------- # +# Spec language: "key=v1,v2,v3" or "key=a:b:s"; clauses joined by ";". +# --------------------------------------------------------------------- # + + +def _coerce(token: str) -> Any: + """Best-effort scalar coercion. Order: None, bool, int, float, str.""" + t = token.strip() + if t == "None": + return None + if t == "True": + return True + if t == "False": + return False + try: + return int(t) + except ValueError: + pass + try: + return float(t) + except ValueError: + pass + return t + + +def _expand_values(s: str) -> list[Any]: + if ":" in s: + parts = s.split(":") + if len(parts) != 3: + raise ValueError(f"Range spec must be 'start:stop:step', got {s!r}") + a, b, step = _coerce(parts[0]), _coerce(parts[1]), _coerce(parts[2]) + if not all(isinstance(x, (int, float)) for x in (a, b, step)): + raise ValueError(f"Range bounds must be numeric, got {s!r}") + if step == 0: + raise ValueError(f"Range step must be non-zero, got {s!r}") + out: list[Any] = [] + v = a + # Small epsilon to keep an inclusive upper bound robust against float drift. + epsilon = abs(step) * 1e-9 if isinstance(step, float) else 0 + # Direction-aware: support a > b with negative step too. + if step > 0: + while v <= b + epsilon: + out.append(v) + v = v + step + else: + while v >= b - epsilon: + out.append(v) + v = v + step + return out + return [_coerce(v) for v in s.split(",") if v.strip()] + + +def parse_params_spec(spec: str | None) -> dict[str, list[Any]]: + """Parse ``"k1=v1,v2;k2=a:b:s"`` into ``{"k1": [v1, v2], "k2": [...]}``.""" + if not spec: + return {} + out: dict[str, list[Any]] = {} + for clause in spec.split(";"): + clause = clause.strip() + if not clause: + continue + if "=" not in clause: + raise ValueError(f"Param clause missing '=': {clause!r}") + key, _, values = clause.partition("=") + out[key.strip()] = _expand_values(values.strip()) + return out + + +def cartesian_grid(spec: dict[str, list[Any]]) -> list[dict[str, Any]]: + """Expand ``{"k1": [a, b], "k2": [c]}`` into ``[{"k1": a, "k2": c}, {"k1": b, "k2": c}]``.""" + if not spec: + return [{}] + keys = list(spec.keys()) + return [dict(zip(keys, combo, strict=True)) for combo in itertools.product(*spec.values())] + + +# --------------------------------------------------------------------- # +# Per-video fan-out driver +# --------------------------------------------------------------------- # + + +def _run_chunk( + source_path: Path, + backend: str, + detector_cls: type, + chunk: list[dict[str, Any]], +) -> list[tuple[list[int], float]]: + """Drive one decode of ``source_path`` and fan out to ``len(chunk)`` parallel detectors. + + Returns one ``(cuts, elapsed)`` pair per chunk entry. ``elapsed`` is wall-clock per + worker thread and is bound by the slowest detector in the chunk, so it is only a + rough indicator of relative cost. + """ + source = open_video(source_path, backend=backend) + fan = FanOutVideoStream(source, n=len(chunk)) + fan.start() + results: list[tuple[list[int], float]] = [([], 0.0) for _ in chunk] + errors: list[BaseException | None] = [None] * len(chunk) + + def worker(i: int, params: dict[str, Any]) -> None: + try: + stream = fan.stream(i) + detector = detector_cls(**params) + sm = SceneManager() + sm.add_detector(detector) + t0 = time.time() + sm.detect_scenes(video=stream) + elapsed = time.time() - t0 + cuts = [scene[1].frame_num for scene in sm.get_scene_list()] + results[i] = (cuts, elapsed) + except BaseException as exc: + errors[i] = exc + fan.abort() + + threads = [threading.Thread(target=worker, args=(i, p)) for i, p in enumerate(chunk)] + try: + for t in threads: + t.start() + for t in threads: + t.join() + finally: + fan.close() + + first_err = next((e for e in errors if e is not None), None) + if first_err is not None: + raise first_err + return results + + +def _chunked(items: list, size: int) -> list[list]: + return [items[i : i + size] for i in range(0, len(items), size)] + + +def run_sweep( + dataset: Dataset, + detector_name: str, + backend: str, + grid: list[dict[str, Any]], + workers: int, +) -> list[dict[Path, Prediction]]: + """For each cell in ``grid``, return a ``{video_path: Prediction}`` mapping suitable + for :func:`benchmark.evaluator.evaluate`. Cells are evaluated in chunks of + ``workers`` parallel detectors per video decode.""" + detector_cls = DETECTORS[detector_name] + # predictions_by_cell[cell_index][video_path] = Prediction + predictions_by_cell: list[dict[Path, Prediction]] = [{} for _ in grid] + pbar = tqdm(dataset, desc=f"sweep[{detector_name}]") + for sample in pbar: + for chunk_indices in _chunked(list(range(len(grid))), workers): + chunk = [grid[i] for i in chunk_indices] + outputs = _run_chunk(sample.video_file, backend, detector_cls, chunk) + for cell_i, (cuts, elapsed) in zip(chunk_indices, outputs, strict=True): + predictions_by_cell[cell_i][sample.video_file] = Prediction( + predicted_cuts=cuts, + ground_truth=sample.ground_truth, + elapsed=elapsed, + ) + return predictions_by_cell + + +# --------------------------------------------------------------------- # +# Reporting +# --------------------------------------------------------------------- # + + +def _params_str(params: dict[str, Any]) -> str: + return ", ".join(f"{k}={v}" for k, v in sorted(params.items())) + + +def _f1_for(result: BenchmarkResult) -> float: + return result.hard_cuts.f1 + + +def _print_top_n( + label: str, + cells: list[tuple[dict[str, Any], BenchmarkResult]], + n: int = 10, +) -> None: + ranked = sorted(cells, key=lambda c: _f1_for(c[1]), reverse=True)[:n] + rows = [] + for params, result in ranked: + hard = result.hard_cuts + rows.append( + [ + f"{hard.f1 * 100:.2f}", + f"{hard.precision * 100:.2f}", + f"{hard.recall * 100:.2f}", + _params_str(params), + ] + ) + if not rows: + return + print(f"\n## {label} (top {min(n, len(ranked))})\n") + print(render_table(["F1", "Precision", "Recall", "Params"], rows)) + + +def _pareto_front( + cells_at_tols: dict[int, list[tuple[dict[str, Any], BenchmarkResult]]], +) -> list[tuple[dict[str, Any], dict[int, float]]]: + """Return cells that are not dominated by any other cell across the given tolerances. + + Domination: cell A dominates B if F1@tol(A) >= F1@tol(B) for every tol and strictly + greater on at least one. Identical (P, R) cells coexist on the frontier. + """ + tols = sorted(cells_at_tols.keys()) + if not tols: + return [] + n_cells = len(cells_at_tols[tols[0]]) + # Build a parallel array of (params, {tol: f1}) entries. + table: list[tuple[dict[str, Any], dict[int, float]]] = [] + for i in range(n_cells): + params = cells_at_tols[tols[0]][i][0] + f1s = {t: _f1_for(cells_at_tols[t][i][1]) for t in tols} + table.append((params, f1s)) + frontier: list[tuple[dict[str, Any], dict[int, float]]] = [] + for i, (pi, fi) in enumerate(table): + dominated = False + for j, (_, fj) in enumerate(table): + if i == j: + continue + if all(fj[t] >= fi[t] for t in tols) and any(fj[t] > fi[t] for t in tols): + dominated = True + break + if not dominated: + frontier.append((pi, fi)) + return frontier + + +def _print_pareto( + cells_at_tols: dict[int, list[tuple[dict[str, Any], BenchmarkResult]]], +) -> None: + frontier = _pareto_front(cells_at_tols) + if len(frontier) <= 1: + return + tols = sorted(cells_at_tols.keys()) + header = [*(f"F1@{t}" for t in tols), "Params"] + rows = [ + [*(f"{f1s[t] * 100:.2f}" for t in tols), _params_str(params)] + for params, f1s in sorted(frontier, key=lambda x: -x[1][tols[0]]) + ] + print(f"\n## Pareto frontier ({len(rows)} cells)\n") + print(render_table(header, rows)) + + +# --------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------- # + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Sweep detector parameters on a benchmark dataset." + ) + parser.add_argument( + "--dataset", + type=str, + required=True, + choices=list(DATASETS.keys()), + help=f"Dataset name. One of: {', '.join(DATASETS.keys())}.", + ) + parser.add_argument( + "--detector", + type=str, + required=True, + choices=list(DETECTORS.keys()), + help=f"Detector name. One of: {', '.join(DETECTORS.keys())}.", + ) + parser.add_argument( + "--params", + type=str, + default="", + help=( + "Parameter spec. Clauses separated by ';'. Each clause is either " + "'key=v1,v2,...' (enumerated values) or 'key=start:stop:step' (numeric range, " + "inclusive of stop when it lands on a step). Omitted keys use the detector's " + "default. For time-valued kwargs like 'min_scene_len', use floats (seconds) " + "so the sweep is framerate-independent, e.g. " + "'threshold=15:35:1;min_scene_len=0.0:1.0:0.1'." + ), + ) + parser.add_argument( + "--dataset-root", + type=str, + default=None, + help="Base directory containing per-dataset subfolders. Defaults to 'benchmark'.", + ) + parser.add_argument( + "--backend", + type=str, + default=DEFAULT_BACKEND, + choices=sorted(AVAILABLE_BACKENDS.keys()), + help=f"Video decoding backend (default: {DEFAULT_BACKEND}).", + ) + parser.add_argument( + "--tolerance", + type=str, + default="0,1", + help="Comma-separated frame tolerances (default: 0,1).", + ) + parser.add_argument( + "--workers", + type=int, + default=8, + help=( + "Number of detector instances to drive in parallel from a single video decode " + "(default: 8). Cells beyond --workers are processed in subsequent chunks, each " + "re-decoding the source video. Memory grows with --workers * prefetch frames." + ), + ) + parser.add_argument( + "--quick", + type=int, + nargs="?", + const=10, + default=None, + metavar="N", + help="Score only the first N samples for fast iteration.", + ) + parser.add_argument( + "--out", + type=str, + default=None, + help="Path to write a machine-readable JSON sweep file (one entry per cell).", + ) + return parser + + +def main() -> None: + args = create_parser().parse_args() + tolerances = parse_tolerances(args.tolerance) + if not tolerances: + raise SystemExit("--tolerance must yield at least one value.") + if args.workers < 1: + raise SystemExit("--workers must be at least 1.") + + spec = parse_params_spec(args.params) + grid = cartesian_grid(spec) + if not grid: + raise SystemExit("Empty parameter grid.") + + dataset = resolve_dataset(args.dataset, args.dataset_root) + if len(dataset) == 0: + raise SystemExit( + f"Dataset {args.dataset!r} at {args.dataset_root or 'benchmark'} is empty - " + "check that videos and annotations are present." + ) + if args.quick is not None: + dataset._samples = dataset._samples[: args.quick] + print(f"--quick: limited to first {len(dataset)} samples") + + print( + f"Sweeping {args.detector} on {args.dataset}: " + f"{len(grid)} cells x {len(dataset)} videos " + f"(backend={args.backend}, workers={args.workers})" + ) + + predictions_by_cell = run_sweep(dataset, args.detector, args.backend, grid, args.workers) + + # Score every cell at every tolerance. + cells_at_tols: dict[int, list[tuple[dict[str, Any], BenchmarkResult]]] = { + t: [ + (params, evaluate(preds, tolerance=t)) + for params, preds in zip(grid, predictions_by_cell, strict=True) + ] + for t in tolerances + } + + for t in tolerances: + _print_top_n(f"Best by F1 @ tolerance={t}", cells_at_tols[t]) + if len(tolerances) >= 2: + _print_pareto(cells_at_tols) + + if args.out: + payload = { + "detector": args.detector, + "dataset": args.dataset, + "backend": args.backend, + "workers": args.workers, + "spec": args.params, + "cells": [ + { + "params": params, + "results": {str(t): cells_at_tols[t][i][1].to_dict() for t in tolerances}, + } + for i, params in enumerate(grid) + ], + } + write_json(args.out, payload) + + +if __name__ == "__main__": + main() diff --git a/dist/generate_assets.py b/dist/generate_assets.py deleted file mode 100644 index a6f71a5b..00000000 --- a/dist/generate_assets.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python -"""Generate pyscenedetect.ico and logo PNGs from SVG sources. - -Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). -""" - -import contextlib -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import NamedTuple - -from PIL import Image, ImageFilter - - -class LogoOutput(NamedTuple): - path: Path - width: int - height: int - source: Path - -# Colors matching the SVG design -BG = (224, 232, 240, 255) # #e0e8f0 -FG = (42, 53, 69, 255) # #2a3545 - -RASTER_SIZES = [16, 24, 32, 48, 64, 128, 256] - -SHARPEN_AMOUNT = { - 24: 75, - 32: 75, - 48: 75, - 64: 100, - 128: 150, - 256: 150, -} - -SHARPEN_RADIUS = 0.5 - -DIST_DIR = Path(__file__).resolve().parent -REPO_DIR = DIST_DIR.parent -LOGO_DIR = DIST_DIR / "logo" -ICO_PATH = DIST_DIR / "pyscenedetect.ico" - -LOGO_SVG = LOGO_DIR / "pyscenedetect-logo.svg" -LOGO_BG_SVG = LOGO_DIR / "pyscenedetect-logo-bg.svg" - -# Heights match the natural SVG aspect ratio (1024x480). -# _small outputs use the -bg variant (background included). -FAVICON_OUTPUTS: list[Path] = [ - REPO_DIR / "docs" / "_static" / "favicon.ico", - REPO_DIR / "website" / "pages" / "img" / "favicon.ico", -] - -LOGO_OUTPUTS: list[LogoOutput] = [ - LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo.png", 900, 422, LOGO_SVG), - LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo_small.png", 300, 141, LOGO_BG_SVG), - LogoOutput(REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo.png", 640, 300, LOGO_BG_SVG), - LogoOutput(REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo_small.png", 462, 217, LOGO_SVG), -] - -SVG_FOR_SIZE: dict[int, Path] = { - 24: LOGO_DIR / "pyscenedetect-24.svg", - 32: LOGO_DIR / "pyscenedetect-32.svg", - 48: LOGO_DIR / "pyscenedetect.svg", - 64: LOGO_DIR / "pyscenedetect.svg", - 128: LOGO_DIR / "pyscenedetect.svg", - 256: LOGO_DIR / "pyscenedetect.svg", -} - - -def make_icon_16() -> Image.Image: - """Create a hand-crafted 16x16 clapperboard icon.""" - img = Image.new("RGBA", (16, 16), FG) - px = img.load() - - # Clear 1px padding on all sides - for i in range(16): - px[0, i] = BG - px[15, i] = BG - px[i, 0] = BG - px[i, 15] = BG - - # Arm stripe gaps (rows 2–4): clear pixels not part of a complete stripe. - # A stripe x+y=s spans all 3 arm rows only when 5 <= s <= 16. - for y in range(2, 5): - for x in range(1, 15): - if y < 4 and x < 3: - continue - if y > 2 and x > 12: - continue - if not ((x + y) % 4 < 2 and 5 <= (x + y) <= 16): - px[x, y] = BG - - # Slate interior (rows 8–12, cols 3–12) - for y in range(8, 13): - for x in range(3, 13): - px[x, y] = BG - - return img - - -def find_inkscape() -> str: - """Find the Inkscape executable.""" - inkscape = shutil.which("inkscape") - if inkscape: - return inkscape - # Common Windows install path - candidate = Path(r"C:\Program Files\Inkscape\bin\inkscape.exe") - if candidate.exists(): - return str(candidate) - print("Error: Inkscape not found. Please install it or add it to PATH.", file=sys.stderr) - sys.exit(1) - - -def render_svg(inkscape: str, svg: Path, output: Path, width: int, height: int): - """Render an SVG to a PNG at the given dimensions using Inkscape.""" - subprocess.run( - [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(width), "-h", str(height)], - check=True, - capture_output=True, - ) - - -def render_logos(inkscape: str): - """Render the logo SVG to all required PNG outputs.""" - print("Rendering logo PNGs...") - for entry in LOGO_OUTPUTS: - print(f" {entry.path.relative_to(REPO_DIR)} ({entry.width}x{entry.height}) [source: {entry.source.name}]...") - render_svg(inkscape, entry.source, entry.path, entry.width, entry.height) - print(f" Done ({len(LOGO_OUTPUTS)} files).") - - -def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: - """Render the SVG at all icon sizes, applying sharpening where configured.""" - images = [] - for size in RASTER_SIZES: - png_path = work_dir / f"icon_{size}.png" - if size == 16: - print(f" Using hand-crafted {size}x{size} icon...") - img = make_icon_16() - img.save(png_path) - else: - svg_path = SVG_FOR_SIZE[size] - print(f" Rendering {size}x{size} using {svg_path.name}...") - render_svg(inkscape, svg_path, png_path, size, size) - img = Image.open(png_path).copy() - if size in SHARPEN_AMOUNT: - img = img.filter(ImageFilter.UnsharpMask(radius=SHARPEN_RADIUS, percent=SHARPEN_AMOUNT[size], threshold=0)) - print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") - img.save(png_path) - images.append(img) - return images - - -def main(): - persist_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else None - if persist_dir: - persist_dir.mkdir(parents=True, exist_ok=True) - print(f"Persisting PNGs to: {persist_dir}") - - inkscape = find_inkscape() - print(f"Using Inkscape: {inkscape}") - print(f"Logo directory: {LOGO_DIR}") - - ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() - with ctx as work: - images = render_all_sizes(inkscape, Path(work)) - images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) - - print(f"Output ICO: {ICO_PATH}") - print("Copying favicons...") - for dest in FAVICON_OUTPUTS: - shutil.copy2(ICO_PATH, dest) - print(f" {dest.relative_to(REPO_DIR)}") - render_logos(inkscape) - - -if __name__ == "__main__": - main() diff --git a/dist/installer/Generated Images/installer_banner.jpg b/dist/installer/Generated Images/installer_banner.jpg deleted file mode 100644 index 2652dd54..00000000 Binary files a/dist/installer/Generated Images/installer_banner.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_banner.scale-125.jpg b/dist/installer/Generated Images/installer_banner.scale-125.jpg deleted file mode 100644 index 09c76450..00000000 Binary files a/dist/installer/Generated Images/installer_banner.scale-125.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_banner.scale-150.jpg b/dist/installer/Generated Images/installer_banner.scale-150.jpg deleted file mode 100644 index 32817838..00000000 Binary files a/dist/installer/Generated Images/installer_banner.scale-150.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_banner.scale-200.jpg b/dist/installer/Generated Images/installer_banner.scale-200.jpg deleted file mode 100644 index 8f1a151f..00000000 Binary files a/dist/installer/Generated Images/installer_banner.scale-200.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_banner.svg b/dist/installer/Generated Images/installer_banner.svg deleted file mode 100644 index 2a9ab287..00000000 --- a/dist/installer/Generated Images/installer_banner.svg +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/dist/installer/Generated Images/installer_logo.jpg b/dist/installer/Generated Images/installer_logo.jpg deleted file mode 100644 index 210bc2b2..00000000 Binary files a/dist/installer/Generated Images/installer_logo.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_logo.scale-125.jpg b/dist/installer/Generated Images/installer_logo.scale-125.jpg deleted file mode 100644 index bb74d9dc..00000000 Binary files a/dist/installer/Generated Images/installer_logo.scale-125.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_logo.scale-150.jpg b/dist/installer/Generated Images/installer_logo.scale-150.jpg deleted file mode 100644 index a504eb8f..00000000 Binary files a/dist/installer/Generated Images/installer_logo.scale-150.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_logo.scale-200.jpg b/dist/installer/Generated Images/installer_logo.scale-200.jpg deleted file mode 100644 index 2a8f1db8..00000000 Binary files a/dist/installer/Generated Images/installer_logo.scale-200.jpg and /dev/null differ diff --git a/dist/installer/Generated Images/installer_logo.svg b/dist/installer/Generated Images/installer_logo.svg deleted file mode 100644 index 894b5b53..00000000 --- a/dist/installer/Generated Images/installer_logo.svg +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip deleted file mode 100644 index 4e692898..00000000 --- a/dist/installer/PySceneDetect.aip +++ /dev/null @@ -1,2041 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dist/installer/installer_banner.png b/dist/installer/installer_banner.png deleted file mode 100644 index 3c4b542f..00000000 Binary files a/dist/installer/installer_banner.png and /dev/null differ diff --git a/dist/installer/installer_banner.svg b/dist/installer/installer_banner.svg deleted file mode 100644 index 681e6c0c..00000000 --- a/dist/installer/installer_banner.svg +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - - - - diff --git a/dist/installer/installer_logo.png b/dist/installer/installer_logo.png deleted file mode 100644 index 11eef793..00000000 Binary files a/dist/installer/installer_logo.png and /dev/null differ diff --git a/dist/installer/installer_logo.svg b/dist/installer/installer_logo.svg deleted file mode 100644 index e4107d7c..00000000 --- a/dist/installer/installer_logo.svg +++ /dev/null @@ -1,154 +0,0 @@ - - - - - - - - - diff --git a/dist/installer/psd_square_small.ico b/dist/installer/psd_square_small.ico deleted file mode 100644 index f42aecb8..00000000 Binary files a/dist/installer/psd_square_small.ico and /dev/null differ diff --git a/dist/logo/pyscenedetect-logo-darkmode.svg b/dist/logo/pyscenedetect-logo-darkmode.svg new file mode 100644 index 00000000..333ee5a1 --- /dev/null +++ b/dist/logo/pyscenedetect-logo-darkmode.svg @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico deleted file mode 100644 index 019c8615..00000000 Binary files a/dist/pyscenedetect.ico and /dev/null differ diff --git a/dist/requirements_windows.txt b/dist/requirements_windows.txt deleted file mode 100644 index ead7d6b3..00000000 --- a/dist/requirements_windows.txt +++ /dev/null @@ -1,13 +0,0 @@ -# PySceneDetect Requirements for Windows Build -av==14.2.0 -click==8.1.8 -opencv-python-headless==4.11.0.86 -imageio-ffmpeg==0.6.0 -moviepy==2.1.2 -numpy==2.2.3 -platformdirs==4.3.6 -tqdm==4.67.1 - -# Build-only and test-only requirements. -pyinstaller -pytest diff --git a/dist/scenedetect.spec b/dist/scenedetect.spec deleted file mode 100644 index 98449645..00000000 --- a/dist/scenedetect.spec +++ /dev/null @@ -1,40 +0,0 @@ -# -*- mode: python -*- - -block_cipher = None - - -a = Analysis(['../scenedetect/__main__.py'], - pathex=['.'], - binaries=None, - datas=[ - ('windows/*', '.'), - ('../LICENSE', '.'), - ('../scenedetect.cfg', '.') - ], - hiddenimports=[], - hookspath=[], - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher) - -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) -exe = EXE(pyz, - a.scripts, - exclude_binaries=True, - name='scenedetect', - debug=False, - strip=False, - upx=True, - console=True, - version='.version_info', - icon='pyscenedetect.ico') -coll = COLLECT(exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - name='scenedetect') diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml deleted file mode 100644 index a9c32f27..00000000 --- a/docs/.readthedocs.yaml +++ /dev/null @@ -1,14 +0,0 @@ -version: 2 - -build: - os: ubuntu-22.04 - tools: - # TODO: Support Python 3.11. - python: "3.10" - -sphinx: - configuration: docs/conf.py - -python: - install: - - requirements: docs/requirements.txt diff --git a/docs/LATEST_VERSION b/docs/LATEST_VERSION new file mode 100644 index 00000000..7deb86fe --- /dev/null +++ b/docs/LATEST_VERSION @@ -0,0 +1 @@ +0.7.1 \ No newline at end of file diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico index 019c8615..bf8cbf10 100644 Binary files a/docs/_static/favicon.ico and b/docs/_static/favicon.ico differ diff --git a/docs/api.rst b/docs/api.rst index 54378ccc..650975b4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,15 +9,15 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.detectors 🕵️ `: detection algorithms: - * :mod:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes + * :class:`AdaptiveDetector `: finds fast cuts using rolling average of HSL changes - * :mod:`ContentDetector `: detects fast cuts using weighted average of HSV changes + * :class:`ContentDetector `: detects fast cuts using weighted average of HSV changes - * :mod:`ThresholdDetector `: finds fades in/out using average pixel intensity changes in RGB + * :class:`ThresholdDetector `: finds fades in/out using average pixel intensity changes in RGB - * :mod:`HistogramDetector ` finds fast cuts using HSV histogram changes + * :class:`HistogramDetector `: finds fast cuts using HSV histogram changes - * :mod:`HashDetector `: finds fast cuts using perceptual image hashing + * :class:`HashDetector `: finds fast cuts using perceptual image hashing * :ref:`scenedetect.output ✂️ `: Output formats: @@ -29,19 +29,21 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.backends 🎥 `: PySceneDetect supports multiple libraries as an input backend: - * OpenCV: :class:`VideoStreamCv2 ` + * OpenCV: :class:`VideoStreamCv2 ` * PyAV: :class:`VideoStreamAv ` * MoviePy: :class:`VideoStreamMoviePy ` + * Multiple videos can be treated as a single continuous stream using :class:`VideoStreamConcat ` (e.g. ``open_video(["part1.mp4", "part2.mp4"])``) + * :ref:`scenedetect.common ⏱️ `: common functionality such as :class:`FrameTimecode ` for timecode handling * :ref:`scenedetect.scene_manager 🎞️ `: the :class:`SceneManager ` coordinates performing scene detection on a video with one or more detectors * :ref:`scenedetect.detector 🌐 `: the interface (:class:`SceneDetector `) that detectors must implement to be compatible with PySceneDetect - * :ref:`scenedetect.video_stream `: the interface (:class:`VideoStream `) that detectors must implement to be compatible with PySceneDetect + * :ref:`scenedetect.video_stream 📹 `: the interface (:class:`VideoStream `) that video backends must implement to be compatible with PySceneDetect * :ref:`scenedetect.stats_manager 🧮 `: the :class:`StatsManager ` allows you to store detection metrics for each frame and save them to CSV for further analysis @@ -56,7 +58,7 @@ Most types/functions are also available directly from the `scenedetect` package .. code:: python - scenedetect<0.8 + scenedetect~=0.7 .. _scenedetect-quickstart: @@ -96,26 +98,6 @@ Functions .. automodule:: scenedetect :members: -======================================================================= -Module Reference -======================================================================= - -.. toctree:: - :maxdepth: 3 - :caption: PySceneDetect Module Documentation - :name: fullapitoc - - api/migration_guide - api/detectors - api/scene_manager - api/common - api/output - api/backends - api/stats_manager - api/detector - api/video_stream - api/platform - ======================================================================= Logging diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 952c131b..8ba7d47c 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -1,9 +1,9 @@ .. _scenedetect-backends: ----------------------------------------- +-------------- Video Backends ----------------------------------------- +-------------- .. automodule:: scenedetect.backends :members: @@ -16,3 +16,6 @@ Video Backends .. automodule:: scenedetect.backends.moviepy :members: + +.. automodule:: scenedetect.backends.concat + :members: diff --git a/docs/api/common.rst b/docs/api/common.rst index ca3a1ede..55cb8310 100644 --- a/docs/api/common.rst +++ b/docs/api/common.rst @@ -1,9 +1,9 @@ .. _scenedetect-common: ---------------------------------------------------------------- +------ Common ---------------------------------------------------------------- +------ .. automodule:: scenedetect.common :members: diff --git a/docs/api/detector.rst b/docs/api/detector.rst index 2263cc7f..97b06d3b 100644 --- a/docs/api/detector.rst +++ b/docs/api/detector.rst @@ -1,9 +1,9 @@ .. _scenedetect-detector: -------------------------------------------------- +------------------ Detector Interface -------------------------------------------------- +------------------ .. automodule:: scenedetect.detector :members: diff --git a/docs/api/detectors.rst b/docs/api/detectors.rst index 486f3f53..5fbe7ff2 100644 --- a/docs/api/detectors.rst +++ b/docs/api/detectors.rst @@ -1,24 +1,53 @@ .. _scenedetect-detectors: ----------------------------------------- +--------- Detectors ----------------------------------------- +--------- .. automodule:: scenedetect.detectors - :members: + +AdaptiveDetector +================ .. automodule:: scenedetect.detectors.adaptive_detector + :no-members: + +.. autoclass:: scenedetect.detectors.adaptive_detector.AdaptiveDetector :members: +ContentDetector +=============== + .. automodule:: scenedetect.detectors.content_detector + :no-members: + +.. autoclass:: scenedetect.detectors.content_detector.ContentDetector :members: +HashDetector +============ + .. automodule:: scenedetect.detectors.hash_detector + :no-members: + +.. autoclass:: scenedetect.detectors.hash_detector.HashDetector :members: +HistogramDetector +================= + .. automodule:: scenedetect.detectors.histogram_detector + :no-members: + +.. autoclass:: scenedetect.detectors.histogram_detector.HistogramDetector :members: +ThresholdDetector +================= + .. automodule:: scenedetect.detectors.threshold_detector + :no-members: + +.. autoclass:: scenedetect.detectors.threshold_detector.ThresholdDetector :members: diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index a3ae9fa7..b481ebff 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -2,7 +2,7 @@ .. _scenedetect-migration-guide: *********************************************************************** -Migration Guide: v0.6 to v0.7 +Migration Guide (v0.7) *********************************************************************** PySceneDetect v0.7 is a major release that overhauls timestamp handling to support variable framerate (VFR) videos. While the high-level :func:`scenedetect.detect` workflow is largely unchanged, several internal APIs have been restructured. This guide covers the changes needed to update applications from v0.6 to v0.7. @@ -56,6 +56,10 @@ Several submodules have been reorganized. If you import directly from `scenedete Most commonly used types and functions are also available directly from the top-level ``scenedetect`` package (e.g. ``from scenedetect import FrameTimecode``), which has not changed. +.. note:: + + The ``frame_timecode``, ``scene_detector``, and ``video_splitter`` submodules emit a ``DeprecationWarning`` when imported directly. The ``save_images``, ``write_scene_list``, and ``write_scene_list_html`` re-exports from ``scenedetect.scene_manager`` continue to work silently in v0.7 but **will be removed in v0.8**. Import these symbols from ``scenedetect`` directly to avoid breakage. + ======================================================================= Custom Detector Changes @@ -80,7 +84,7 @@ The ``frame_num`` parameter (``int``) has been replaced with ``timecode`` (:clas def process_frame(self, timecode: FrameTimecode, frame_img) -> List[FrameTimecode]: ... -The same change applies to ``post_process()``. If you need the frame number, use ``timecode.frame_num``. +The same change applies to ``post_process()``. Using units of time instead of frame numbers is critical for temporal accuracy. If you need the frame number, use ``timecode.frame_num`` to the timecode to an integer. ``SceneDetector`` is Now Abstract ----------------------------------------------------------------------- @@ -104,32 +108,52 @@ The following have been removed from the ``SceneDetector`` interface: Read-Only Properties ----------------------------------------------------------------------- -``frame_num`` and ``framerate`` are now read-only properties. To change them, construct a new ``FrameTimecode``: +:attr:`~scenedetect.common.FrameTimecode.frame_num` and :attr:`~scenedetect.common.FrameTimecode.frame_rate` are now read-only properties. To change them, construct a new ``FrameTimecode``: .. code:: python - # v0.6 - direct assignment - tc.frame_num = 100 # No longer works - - # v0.7 - construct new instance - tc = FrameTimecode(100, tc.framerate) + tc = FrameTimecode(0, 24.0) + # Can no longer reassign frame_num, must create a new FrameTimecode instead: + #tc.frame_num = 100 + tc = FrameTimecode(100, tc) New Properties ----------------------------------------------------------------------- -Access ``frame_num``, ``framerate``, and ``seconds`` as properties instead of getter methods: +Access :attr:`~scenedetect.common.FrameTimecode.frame_num`, :attr:`~scenedetect.common.FrameTimecode.frame_rate`, and :attr:`~scenedetect.common.FrameTimecode.seconds` as properties instead of getter methods. The new :attr:`~scenedetect.common.FrameTimecode.frame_rate` property returns an exact :class:`fractions.Fraction` and matches :attr:`~scenedetect.video_stream.VideoStream.frame_rate`: .. code:: python - tc = FrameTimecode(100, 24.0) - tc.frame_num # 100 - tc.framerate # Fraction(24, 1) - tc.seconds # ~4.167 + from fractions import Fraction + tc = FrameTimecode(100, 29.97) + tc.frame_num # 100 + tc.frame_rate # Fraction(30000, 1001) (exact) + tc.time_base # Fraction(1001, 30000) + tc.seconds # ~3.337 + +``time_base`` equals ``1 / frame_rate`` for CFR sources. For VFR (``Timecode``-backed) instances, ``time_base`` is authoritative and ``frame_rate`` is an approximation. + +``Fraction`` participates in numeric arithmetic and comparisons just like ``float``, and converts implicitly when mixed with floats (the result is a ``float``). When a ``float`` is explicitly required (e.g. format specifiers, ``json.dumps``, ``isinstance(x, float)`` checks) wrap the value with ``float(...)``: + +.. code:: python + + rate = tc.frame_rate # Fraction(30000, 1001) + rate * 2 # Fraction(60000, 1001) + rate > 24 # True + rate * 0.5 # 14.985... (float, mixed arithmetic) + f"{float(rate):.3f}" # '29.970' (explicit cast for format spec) + +The legacy ``framerate`` property (one word, returns ``float``) is retained as a deprecated alias and will emit a ``DeprecationWarning`` in a future release. Migrate to ``frame_rate``; cast with ``float(...)`` at the call site if you specifically need a ``float``. + +Renamed Method: ``equal_framerate()`` +----------------------------------------------------------------------- + +:meth:`~scenedetect.common.FrameTimecode.equal_framerate` has been renamed to :meth:`~scenedetect.common.FrameTimecode.equal_frame_rate` for consistency with the :attr:`~scenedetect.common.FrameTimecode.frame_rate` property. The legacy ``equal_framerate()`` method is retained as a deprecated alias and will emit a ``DeprecationWarning`` in a future release. The new form additionally accepts a ``Fraction`` or another ``FrameTimecode`` (in addition to ``float``). Removed Methods ----------------------------------------------------------------------- -- ``previous_frame()`` - removed, use ``FrameTimecode(tc.frame_num - 1, tc.framerate)`` instead +- ``previous_frame()`` - removed, use ``FrameTimecode(tc.frame_num - 1, tc)`` instead (passing a ``FrameTimecode`` as the ``fps`` argument reuses its rate) ======================================================================= @@ -139,7 +163,7 @@ Framerate and Timestamp Changes Rational Framerates ----------------------------------------------------------------------- -``VideoStream.frame_rate`` now returns a ``Fraction`` instead of ``float``. Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values: +:attr:`~scenedetect.video_stream.VideoStream.frame_rate` now returns a ``Fraction`` instead of ``float``. Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values: .. code:: python @@ -148,19 +172,62 @@ Rational Framerates assert isinstance(video.frame_rate, Fraction) # e.g. Fraction(24000, 1001) instead of 23.976023976... +``frame_rate`` Keyword Argument +----------------------------------------------------------------------- + +The ``framerate`` keyword argument has been renamed to ``frame_rate`` on :func:`~scenedetect.open_video` and on every backend constructor (:class:`~scenedetect.backends.opencv.VideoStreamCv2`, :class:`~scenedetect.backends.opencv.VideoCaptureAdapter`, :class:`~scenedetect.backends.pyav.VideoStreamAv`, :class:`~scenedetect.backends.moviepy.VideoStreamMoviePy`). The new form accepts ``float | Fraction | None``. The legacy ``framerate`` keyword is retained as a deprecated alias and will emit a ``DeprecationWarning`` in a future release; if both are supplied, ``frame_rate`` takes precedence. + +.. code:: python + + # v0.6 - will still work but will be removed in a future version + video = open_video("video.mp4", framerate=30.0) + + # v0.7 + video = open_video("video.mp4", frame_rate=30.0) + PTS-Backed Timestamps ----------------------------------------------------------------------- -All backends now return presentation timestamp (PTS) backed values from ``VideoStream.position``. This enables correct handling of VFR videos. +All backends now return presentation timestamp (PTS) backed values from :attr:`~scenedetect.video_stream.VideoStream.position`. This enables correct handling of VFR videos. -``FrameTimecode`` has new ``time_base`` and ``pts`` properties for accessing the underlying timing information. For VFR videos, ``frame_num`` is now an approximation based on PTS-derived time rather than a sequential count. +``FrameTimecode`` has new :attr:`~scenedetect.common.FrameTimecode.time_base` and :attr:`~scenedetect.common.FrameTimecode.pts` properties for accessing the underlying timing information. For VFR videos, :attr:`~scenedetect.common.FrameTimecode.frame_num` is now an approximation based on PTS-derived time rather than a sequential count. ======================================================================= ``StatsManager`` Changes ======================================================================= -The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now take a ``FrameTimecode`` instead of ``int`` for the frame identifier, matching the detector interface change. +The ``StatsManager`` methods :meth:`~scenedetect.stats_manager.StatsManager.get_metrics`, :meth:`~scenedetect.stats_manager.StatsManager.set_metrics`, and :meth:`~scenedetect.stats_manager.StatsManager.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 +======================================================================= + +:meth:`~scenedetect.detector.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 of :meth:`~scenedetect.scene_manager.SceneManager.detect_scenes` 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, end_time=15.0) # seconds + scene_manager.detect_scenes(video, end_time=1500) # frames + scene_manager.detect_scenes(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. ======================================================================= @@ -182,7 +249,7 @@ The following deprecated APIs have been fully removed in v0.7: * - ``video_manager`` parameter (various functions) - Use ``video`` parameter instead * - ``SceneManager.get_event_list()`` - - Use ``SceneManager.get_cut_list()`` or ``SceneManager.get_scene_list()`` + - Use :meth:`~scenedetect.scene_manager.SceneManager.get_cut_list` or :meth:`~scenedetect.scene_manager.SceneManager.get_scene_list` * - ``AdaptiveDetector.get_content_val()`` - Use ``StatsManager`` to query metrics * - ``AdaptiveDetector(min_delta_hsv=...)`` @@ -201,6 +268,23 @@ The following deprecated APIs have been fully removed in v0.7: CLI Changes ======================================================================= +Removed / Renamed +----------------------------------------------------------------------- + - The ``-d``/``--min-delta-hsv`` option on ``detect-adaptive`` has been removed. Use ``-c``/``--min-content-val`` instead. +- The global ``--framerate`` flag has been renamed to ``-f``/``--frame-rate`` for consistency with the API. The legacy ``--framerate`` form is retained as a hidden alias and will be removed in v0.8; if both are supplied, ``--frame-rate`` takes precedence and a warning is logged. +- The ``export-html`` command has been renamed to :ref:`save-html `. The legacy ``export-html`` command is retained as a deprecated alias and emits a deprecation warning when used. + +New Commands and Options +----------------------------------------------------------------------- + +- New :ref:`save-fcp ` command exports scenes in Final Cut Pro XML format (FCP7/FCPX). +- New :ref:`save-qp ` command writes a QP file with scene boundary frame numbers, suitable for forcing keyframes at scene cuts in x264/x265. +- New :ref:`save-html ` command (replaces ``export-html``). +- New ``-s``/``--start-timecode`` option on :ref:`save-edl ` provides a custom start timecode for generated EDLs (SMPTE ``HH:MM:SS:FF`` or 8-digit ``HHMMSSFF``). + +Other Changes +----------------------------------------------------------------------- + - 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. +- All CLI options that previously accepted only frame numbers now also accept seconds (e.g. ``0.6s``) and timecodes (e.g. ``00:00:00.600``). diff --git a/docs/api/output.rst b/docs/api/output.rst index 480e4371..6c3abb37 100644 --- a/docs/api/output.rst +++ b/docs/api/output.rst @@ -1,28 +1,9 @@ - .. _scenedetect-output: -------------------------------------------------- -Ouptut -------------------------------------------------- - -.. autofunction:: scenedetect.output.save_images - -.. autofunction:: scenedetect.output.is_ffmpeg_available - -.. autofunction:: scenedetect.output.split_video_ffmpeg - -.. autofunction:: scenedetect.output.is_mkvmerge_available - -.. autofunction:: scenedetect.output.split_video_mkvmerge - -.. autofunction:: scenedetect.output.write_scene_list_html - -.. autofunction:: scenedetect.output.write_scene_list - -.. autoclass:: scenedetect.output.SceneMetadata - -.. autoclass:: scenedetect.output.VideoMetadata - -.. autofunction:: scenedetect.output.default_formatter +------ +Output +------ +.. automodule:: scenedetect.output + :members: diff --git a/docs/api/platform.rst b/docs/api/platform.rst index 58929904..134fa2d1 100644 --- a/docs/api/platform.rst +++ b/docs/api/platform.rst @@ -1,9 +1,9 @@ .. _scenedetect-platform: ---------------------------------------------------------------- +------------------ Platform & Logging ---------------------------------------------------------------- +------------------ .. automodule:: scenedetect.platform - :members: + :members: diff --git a/docs/api/scene_manager.rst b/docs/api/scene_manager.rst index fa47f743..2a0ee6a3 100644 --- a/docs/api/scene_manager.rst +++ b/docs/api/scene_manager.rst @@ -1,9 +1,9 @@ .. _scenedetect-scene_manager: ------------------------------------------------------------------------ +------------- Scene Manager ------------------------------------------------------------------------ +------------- .. automodule:: scenedetect.scene_manager :members: diff --git a/docs/api/stats_manager.rst b/docs/api/stats_manager.rst index 2ce4c806..5f96dec0 100644 --- a/docs/api/stats_manager.rst +++ b/docs/api/stats_manager.rst @@ -1,9 +1,9 @@ .. _scenedetect-stats_manager: ------------------------------------------------------------------------ +------------- Stats Manager ------------------------------------------------------------------------ +------------- .. automodule:: scenedetect.stats_manager :members: diff --git a/docs/api/video_stream.rst b/docs/api/video_stream.rst index c6d8563e..a090d6d9 100644 --- a/docs/api/video_stream.rst +++ b/docs/api/video_stream.rst @@ -1,9 +1,9 @@ .. _scenedetect-video_stream: ---------------------------------------------------------------- +---------------- Stream Interface ---------------------------------------------------------------- +---------------- .. automodule:: scenedetect.video_stream :members: diff --git a/docs/cli.rst b/docs/cli.rst index 6df3d9f7..b84fc60c 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -61,9 +61,9 @@ Options Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis. -.. option:: -f FPS, --framerate FPS +.. option:: -f FPS, --framerate FPS, --frame-rate FPS - Override framerate with value as frames/sec. + Override frame rate with value as frames/sec. .. option:: -m TIMECODE, --min-scene-len TIMECODE @@ -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. @@ -310,13 +308,13 @@ Options Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are more sensitive to changes. - Default: ``0.395`` + Default: ``0.35`` .. option:: -s SIZE, --size SIZE Size of square of low frequency data to include from the discrete cosine transform. - Default: ``16`` + Default: ``8`` .. option:: -l FRAC, --lowpass FRAC @@ -361,13 +359,13 @@ Options Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower values are more sensitive to changes. - Default: ``0.05`` + Default: ``0.2`` .. option:: -b NUM, --bins NUM The number of bins to use for the histogram calculation. - Default: ``256`` + Default: ``128`` .. option:: -m TIMECODE, --min-scene-len TIMECODE @@ -551,6 +549,42 @@ Options Output directory to save EDL file to. Overrides global option :option:`-o/--output `. +.. option:: -s TIMECODE, --start-timecode TIMECODE + + 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). + + +.. _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: @@ -658,11 +692,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 @@ -827,6 +861,10 @@ Options Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than :option:`-f/--filename <-f>`, :option:`-q/--quiet <-q>` and :option:`-o/--output <-o>` will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix. +.. option:: --expand + + Extend the first/last output clips to cover the full input video, even if `time -s/-e` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split. + .. _command-time: diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst index 8df76a58..7c235915 100644 --- a/docs/cli/backends.rst +++ b/docs/cli/backends.rst @@ -19,7 +19,9 @@ The `OpenCV `_ backend (usually `opencv-python `_ backend (`av package `_) is a more robust backend that handles multiple audio tracks and frame decode errors gracefully. +Variable framerate (VFR) video is fully supported. PyAV uses native PTS timestamps directly from the container, giving the most accurate timecodes for VFR content. + This backend can be used by specifying ``-b pyav`` via command line, or setting ``backend = pyav`` under the ``[global]`` section of your :ref:`config file `. @@ -41,4 +45,6 @@ MoviePy launches ffmpeg as a subprocess, and can be used with various types of i The MoviePy backend is still under development and is not included with current Windows distribution. To enable MoviePy support, you must install PySceneDetect using `python` and `pip`. + Variable framerate (VFR) video is **not supported**. MoviePy assumes a fixed framerate, so timecodes for VFR content will be inaccurate. Use the PyAV or OpenCV backend instead. + This backend can be used by specifying ``-b moviepy`` via command line, or setting ``backend = moviepy`` under the ``[global]`` section of your :ref:`config file `. diff --git a/docs/conf.py b/docs/conf.py index 72ade37d..a73c362c 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 @@ -37,6 +37,7 @@ extensions = [ "sphinx.ext.napoleon", "sphinx.ext.autodoc", + "sphinx_copybutton", ] autoclass_content = "both" @@ -44,6 +45,9 @@ autodoc_typehints = "description" autodoc_typehints_format = "short" +add_module_names = False +python_use_unqualified_type_names = True + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -78,7 +82,7 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] html_css_files = ["pyscenedetect.css"] -html_favicon = "favicon.ico" +html_favicon = "_static/favicon.ico" # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -149,7 +153,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 a1ba2eca..68542292 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. @@ -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] @@ -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: @@ -80,10 +80,10 @@ 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: - return ":ref:`%s `" % (command, command) + def add_link(_match: re.Match, command: str = command) -> str: + 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,59 +107,61 @@ 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.""" 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()] 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]]: - default = re.search("\[default: .*\]", s) +def extract_default_value(s: str) -> tuple[str, str | None]: + default = re.search(r"\[default: .*\]", s) if default is not None: span = default.span() assert span[1] == len(s) 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. # -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\)]+(? 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,14 +250,16 @@ 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(filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands)) + commands: list[str] = ctx.command.list_commands(ctx) + 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), + generate_title("``scenedetect`` \N{CLAPPER BOARD} Command", level=0), generate_command_help(ctx, ctx.command), generate_subcommands(ctx, commands), ] @@ -268,7 +272,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/docs/index.rst b/docs/index.rst index f3de773a..1fc92ea0 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 @@ -8,7 +8,7 @@ PySceneDetect Documentation Welcome to the PySceneDetect docs. The docs are split into two separate parts: one for the command-line interface (the `scenedetect` command) and another for the Python API (the `scenedetect` module). -You can install the latest release of PySceneDetect by running `pip install scenedetect[opencv]` or downloading the Windows build from `scenedetect.com/download `_. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. +You can install the latest release of PySceneDetect by running `pip install scenedetect` (or `pip install scenedetect-headless` on servers without GUI libraries), or by downloading the Windows build from `scenedetect.com/download `_. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. .. note:: @@ -54,6 +54,7 @@ Table of Contents api/video_stream api/stats_manager api/platform + api/migration_guide ======================================================================= Indices and Tables diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 051c203c..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# These are requirements only for the docs. -Sphinx == 7.0.1 diff --git a/packaging/build_all.py b/packaging/build_all.py new file mode 100644 index 00000000..3ff99089 --- /dev/null +++ b/packaging/build_all.py @@ -0,0 +1,103 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://www.scenedetect.com/docs/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# +"""Builds the two published PySceneDetect distributions into dist/: + + - scenedetect / scenedetect-headless: the full package (code, an OpenCV variant, + the CLI dependencies, and the `scenedetect` console script), produced by + temporarily swapping packaging/variants/pyproject-.toml into the repo + root (restored afterwards, even on failure) + +Both are standalone code-carrying packages built from the repo root, so they share +the same source, readme, and dynamic version. The root pyproject.toml +(`scenedetect-core`) is a development/local-install configuration only and is NOT +built or published here: scenedetect-core 0.7.1 was briefly published and then +yanked - layering packages over a shared core dist is unsafe with pip (co-installed +variants double-own files, and converting an existing code-carrying name to a +metapackage breaks in-place upgrades; see https://scenedetect.com/issues/558). + +Requires `build` (pip install build). Fails if dist/ ends up with any wheel/sdist +besides the four expected artifacts, so clear stale build artifacts from dist/ first. +(Other dist/ contents are ignored - e.g. dist/logo/ is tracked website assets.) +""" + +import ast +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DIST = ROOT / "dist" +PYPROJECT = ROOT / "pyproject.toml" +VARIANTS = ("scenedetect", "scenedetect-headless") + + +def get_version() -> str: + """Parse scenedetect.__version__ without importing (avoids the cv2 guard), + normalized per PEP 440 (e.g. 0.7.1-dev0 -> 0.7.1.dev0).""" + source = (ROOT / "scenedetect" / "__init__.py").read_text(encoding="utf-8") + for node in ast.parse(source).body: + if isinstance(node, ast.Assign) and any( + getattr(target, "id", None) == "__version__" for target in node.targets + ): + assert isinstance(node.value, ast.Constant) + return str(node.value.value).replace("-", ".") + raise SystemExit("Could not find __version__ in scenedetect/__init__.py") + + +def build() -> None: + subprocess.check_call([sys.executable, "-m", "build", "--outdir", str(DIST), str(ROOT)]) + + +def main() -> None: + version = get_version() + + original = PYPROJECT.read_text(encoding="utf-8") + if 'name = "scenedetect-core"' not in original: + raise SystemExit( + "pyproject.toml is not the scenedetect-core baseline - likely left over " + "from an interrupted build. Restore it (e.g. `git checkout pyproject.toml`) " + "and re-run." + ) + + try: + for name in VARIANTS: + variant = (ROOT / "packaging" / "variants" / f"pyproject-{name}.toml").read_text( + encoding="utf-8" + ) + assert f'name = "{name}"' in variant, f"unexpected package name in variant {name}" + PYPROJECT.write_text(variant, encoding="utf-8") + build() + finally: + PYPROJECT.write_text(original, encoding="utf-8") + + expected = set() + for name in VARIANTS: + normalized = name.replace("-", "_") + expected.add(f"{normalized}-{version}.tar.gz") + expected.add(f"{normalized}-{version}-py3-none-any.whl") + # Only validate build artifacts: dist/ also holds tracked files (e.g. dist/logo/). + actual = { + path.name + for path in DIST.iterdir() + if path.is_file() and (path.name.endswith(".whl") or path.name.endswith(".tar.gz")) + } + if actual != expected: + raise SystemExit( + f"dist/ mismatch (stale files or failed build?)\n" + f" missing: {sorted(expected - actual)}\n" + f" unexpected: {sorted(actual - expected)}" + ) + print(f"Built {len(expected)} artifacts for version {version}:") + for filename in sorted(expected): + print(f" dist/{filename}") + + +if __name__ == "__main__": + main() diff --git a/dist/logo/pyscenedetect-24.svg b/packaging/logo/pyscenedetect-24.svg similarity index 100% rename from dist/logo/pyscenedetect-24.svg rename to packaging/logo/pyscenedetect-24.svg diff --git a/dist/logo/pyscenedetect-32.svg b/packaging/logo/pyscenedetect-32.svg similarity index 100% rename from dist/logo/pyscenedetect-32.svg rename to packaging/logo/pyscenedetect-32.svg diff --git a/dist/logo/pyscenedetect-logo-bg.svg b/packaging/logo/pyscenedetect-logo-bg.svg similarity index 100% rename from dist/logo/pyscenedetect-logo-bg.svg rename to packaging/logo/pyscenedetect-logo-bg.svg diff --git a/dist/logo/pyscenedetect-logo.svg b/packaging/logo/pyscenedetect-logo.svg similarity index 100% rename from dist/logo/pyscenedetect-logo.svg rename to packaging/logo/pyscenedetect-logo.svg diff --git a/packaging/logo/pyscenedetect-new.svg b/packaging/logo/pyscenedetect-new.svg new file mode 100644 index 00000000..fb383852 --- /dev/null +++ b/packaging/logo/pyscenedetect-new.svg @@ -0,0 +1,41 @@ + + + + + + + + + diff --git a/dist/logo/pyscenedetect.svg b/packaging/logo/pyscenedetect.svg similarity index 100% rename from dist/logo/pyscenedetect.svg rename to packaging/logo/pyscenedetect.svg diff --git a/dist/package-info.rst b/packaging/package-info.rst similarity index 78% rename from dist/package-info.rst rename to packaging/package-info.rst index 94fdfc8e..1721e516 100644 --- a/dist/package-info.rst +++ b/packaging/package-info.rst @@ -12,7 +12,7 @@ Video Scene Cut Detection and Analysis Tool :target: https://github.com/Breakthrough/PySceneDetect .. image:: https://img.shields.io/pypi/l/scenedetect.svg - :target: http://pyscenedetect.readthedocs.org/en/latest/copyright/ + :target: https://www.scenedetect.com/copyright/ .. image:: https://img.shields.io/github/stars/Breakthrough/PySceneDetect.svg?style=social&label=View%20on%20Github :target: https://github.com/Breakthrough/PySceneDetect @@ -23,7 +23,9 @@ Documentation: https://www.scenedetect.com/docs Github Repo: https://github.com/Breakthrough/PySceneDetect/ -Install: ``pip install --upgrade scenedetect[opencv]`` +Install: ``pip install --upgrade scenedetect`` (or ``scenedetect-headless`` for servers) + +Packages: `scenedetect `_ (CLI + ``opencv-python``) and `scenedetect-headless `_ (CLI + ``opencv-python-headless``). Both provide the same ``scenedetect`` module -- install only one. ---------------------------------------------------------- @@ -43,6 +45,6 @@ You can also use the Python API (`docs . +# +# pyproject.toml for the `scenedetect-headless` package: the same code as +# scenedetect-core, but bundling the opencv-python-headless OpenCV variant (no GUI +# libraries, for servers/containers), the CLI dependencies, and the `scenedetect` +# console script. packaging/build_all.py temporarily swaps this file into the repo +# root and builds from there, so the code, readme, and dynamic version are shared +# with the other variants. +# +# Keep [project] metadata (classifiers, requires-python, etc.) in sync with the +# root pyproject.toml and pyproject-scenedetect.toml. Only the name, description, +# dependencies, extras, and console script differ between variants. + +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "scenedetect-headless" +dynamic = ["version"] +description = "Video scene cut/shot detection program and Python library (bundles the opencv-python-headless OpenCV variant and the scenedetect CLI; for servers without GUI libraries)." +readme = { file = "packaging/package-info.rst", content-type = "text/x-rst" } +license = "BSD-3-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Brandon Castellano", email = "brandon248@gmail.com" }] +keywords = ["video", "computer-vision", "analysis"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: Console :: Curses", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", + "Topic :: Multimedia :: Video :: Non-Linear Editor", + "Topic :: Utilities", +] +dependencies = [ + # click 8.3.0 specifically is excluded per https://scenedetect.com/issues/521; 8.3.1+ are fine. + "click~=8.0,!=8.3.0", + "numpy", + "opencv-python-headless", + "platformdirs", + "tqdm", +] + +[project.optional-dependencies] +pyav = ["av>=9.2"] +moviepy = ["moviepy"] + +[project.urls] +Homepage = "https://www.scenedetect.com" +Documentation = "https://www.scenedetect.com/docs/" +Source = "https://github.com/Breakthrough/PySceneDetect" +Issues = "https://github.com/Breakthrough/PySceneDetect/issues" + +[project.scripts] +scenedetect = "scenedetect.__main__:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["scenedetect*"] + +[tool.setuptools.dynamic] +version = { attr = "scenedetect.__version__" } diff --git a/packaging/variants/pyproject-scenedetect.toml b/packaging/variants/pyproject-scenedetect.toml new file mode 100644 index 00000000..5aae0f41 --- /dev/null +++ b/packaging/variants/pyproject-scenedetect.toml @@ -0,0 +1,81 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://www.scenedetect.com/docs/ ] +# +# Copyright (C) 2014 Brandon Castellano . +# +# pyproject.toml for the `scenedetect` package: the same code as scenedetect-core, +# but bundling the opencv-python (GUI-capable) OpenCV variant, the CLI dependencies, +# and the `scenedetect` console script. packaging/build_all.py temporarily swaps +# this file into the repo root and builds from there, so the code, readme, and +# dynamic version are shared with the other variants. +# +# Keep [project] metadata (classifiers, requires-python, etc.) in sync with the +# root pyproject.toml and pyproject-scenedetect-headless.toml. Only the name, +# description, dependencies, extras, and console script differ between variants. + +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "scenedetect" +dynamic = ["version"] +description = "Video scene cut/shot detection program and Python library (bundles the opencv-python OpenCV variant and the scenedetect CLI)." +readme = { file = "packaging/package-info.rst", content-type = "text/x-rst" } +license = "BSD-3-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Brandon Castellano", email = "brandon248@gmail.com" }] +keywords = ["video", "computer-vision", "analysis"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: Console :: Curses", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", + "Topic :: Multimedia :: Video :: Non-Linear Editor", + "Topic :: Utilities", +] +dependencies = [ + # click 8.3.0 specifically is excluded per https://scenedetect.com/issues/521; 8.3.1+ are fine. + "click~=8.0,!=8.3.0", + "numpy", + "opencv-python", + "platformdirs", + "tqdm", +] + +[project.optional-dependencies] +pyav = ["av>=9.2"] +moviepy = ["moviepy"] + +[project.urls] +Homepage = "https://www.scenedetect.com" +Documentation = "https://www.scenedetect.com/docs/" +Source = "https://github.com/Breakthrough/PySceneDetect" +Issues = "https://github.com/Breakthrough/PySceneDetect/issues" + +[project.scripts] +scenedetect = "scenedetect.__main__:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["scenedetect*"] + +[tool.setuptools.dynamic] +version = { attr = "scenedetect.__version__" } diff --git a/dist/windows/LICENSE-PYTHON b/packaging/windows/LICENSE-PYTHON similarity index 100% rename from dist/windows/LICENSE-PYTHON rename to packaging/windows/LICENSE-PYTHON diff --git a/dist/windows/README.txt b/packaging/windows/README.txt similarity index 100% rename from dist/windows/README.txt rename to packaging/windows/README.txt diff --git a/packaging/windows/installer/Generated Images/installer_banner.svg b/packaging/windows/installer/Generated Images/installer_banner.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/packaging/windows/installer/Generated Images/installer_banner.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/packaging/windows/installer/Generated Images/installer_logo.svg b/packaging/windows/installer/Generated Images/installer_logo.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/packaging/windows/installer/Generated Images/installer_logo.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/dist/installer/Prerequisites/Visual C++ Redistributable for Visual Studio 2015-2019/VC_redist.x64.exe b/packaging/windows/installer/Prerequisites/Visual C++ Redistributable for Visual Studio 2015-2019/VC_redist.x64.exe similarity index 100% rename from dist/installer/Prerequisites/Visual C++ Redistributable for Visual Studio 2015-2019/VC_redist.x64.exe rename to packaging/windows/installer/Prerequisites/Visual C++ Redistributable for Visual Studio 2015-2019/VC_redist.x64.exe diff --git a/packaging/windows/installer/PySceneDetect.aip b/packaging/windows/installer/PySceneDetect.aip new file mode 100644 index 00000000..7865d2a2 --- /dev/null +++ b/packaging/windows/installer/PySceneDetect.aip @@ -0,0 +1,2179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer/installer_banner.png b/packaging/windows/installer/installer_banner.png new file mode 100644 index 00000000..192c26e6 Binary files /dev/null and b/packaging/windows/installer/installer_banner.png differ diff --git a/packaging/windows/installer/installer_banner.svg b/packaging/windows/installer/installer_banner.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/packaging/windows/installer/installer_banner.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/packaging/windows/installer/installer_logo.png b/packaging/windows/installer/installer_logo.png new file mode 100644 index 00000000..e1d32d2b Binary files /dev/null and b/packaging/windows/installer/installer_logo.png differ diff --git a/packaging/windows/installer/installer_logo.svg b/packaging/windows/installer/installer_logo.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/packaging/windows/installer/installer_logo.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/dist/installer/license65.dat.enc b/packaging/windows/installer/license65.dat.enc similarity index 100% rename from dist/installer/license65.dat.enc rename to packaging/windows/installer/license65.dat.enc diff --git a/packaging/windows/installer/psd_square_small.ico b/packaging/windows/installer/psd_square_small.ico new file mode 100644 index 00000000..bf8cbf10 Binary files /dev/null and b/packaging/windows/installer/psd_square_small.ico differ diff --git a/packaging/windows/pyi_rth_scenedetect.py b/packaging/windows/pyi_rth_scenedetect.py new file mode 100644 index 00000000..a233ec52 --- /dev/null +++ b/packaging/windows/pyi_rth_scenedetect.py @@ -0,0 +1,28 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://www.scenedetect.com/docs/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# +# Runtime hook: redirect imageio_ffmpeg and moviepy to the bundled ffmpeg.exe (staged next to +# scenedetect.exe) so we ship a single copy of ffmpeg. Runs before any user imports, which is +# required because moviepy.config reads FFMPEG_BINARY at import time. + + +def _pyi_rthook(): + import os + import sys + + bundle_dir = os.path.dirname(sys.executable) + ffmpeg_exe = os.path.join(bundle_dir, "ffmpeg.exe") + if os.path.isfile(ffmpeg_exe): + os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_exe + os.environ.setdefault("FFMPEG_BINARY", ffmpeg_exe) + os.environ["PATH"] = bundle_dir + os.pathsep + os.environ.get("PATH", "") + + +_pyi_rthook() +del _pyi_rthook diff --git a/packaging/windows/pyscenedetect.ico b/packaging/windows/pyscenedetect.ico new file mode 100644 index 00000000..bf8cbf10 Binary files /dev/null and b/packaging/windows/pyscenedetect.ico differ diff --git a/packaging/windows/requirements.txt b/packaging/windows/requirements.txt new file mode 100644 index 00000000..dc31fa21 --- /dev/null +++ b/packaging/windows/requirements.txt @@ -0,0 +1,16 @@ +# PySceneDetect Requirements for Windows Build +# NOTE: pillow (transitive, via moviepy) is overridden to 12.3.0 in appveyor.yml for CVE fixes +# (see https://github.com/Zulko/moviepy/issues/2553). +av==18.0.0 +click==8.4.2 +imageio-ffmpeg==0.6.0 +moviepy==2.2.1 +opencv-python-headless==5.0.0.93 +numpy==2.5.1 +platformdirs==4.11.0 +tqdm==4.69.0 + +# Build-only and test-only requirements. +pyinstaller +pytest +pytest-rerunfailures diff --git a/packaging/windows/scenedetect.spec b/packaging/windows/scenedetect.spec new file mode 100644 index 00000000..4941a55b --- /dev/null +++ b/packaging/windows/scenedetect.spec @@ -0,0 +1,68 @@ +# -*- mode: python -*- + +import os + +from PyInstaller.utils.hooks import copy_metadata + +block_cipher = None + +# moviepy/imageio resolve their own version via importlib.metadata at import time, +# which needs the dist-info dirs bundled alongside the modules. +_metadata = ( + copy_metadata('moviepy') + + copy_metadata('imageio') + + copy_metadata('imageio_ffmpeg') +) + + +a = Analysis(['../../scenedetect/__main__.py'], + pathex=['.'], + binaries=None, + datas=[ + ('LICENSE-PYTHON', '.'), + ('README.txt', '.'), + ('../../LICENSE', '.'), + ('../../scenedetect.cfg', '.') + ] + _metadata, + hiddenimports=['moviepy', 'imageio', 'imageio_ffmpeg'], + hookspath=[], + runtime_hooks=['packaging/windows/pyi_rth_scenedetect.py'], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher) + +# Drop imageio_ffmpeg's bundled ffmpeg-*.exe so we don't ship two copies of +# ffmpeg. The runtime hook (pyi_rth_scenedetect.py) redirects imageio_ffmpeg +# and moviepy at the GyanD ffmpeg.exe staged next to scenedetect.exe by +# scripts/stage_windows_dist.py. Keep __init__.py — pyinstaller-hooks-contrib +# declares `imageio_ffmpeg.binaries` as a hidden import, so the package still +# has to be importable. +def _drop_bundled_ffmpeg(toc): + # TOC dest paths use the OS-native separator, so normalize before matching. + prefix = 'imageio_ffmpeg' + os.sep + 'binaries' + os.sep + return [t for t in toc if not ( + t[0].startswith(prefix) and not t[0].endswith('__init__.py') + )] +a.binaries = _drop_bundled_ffmpeg(a.binaries) +a.datas = _drop_bundled_ffmpeg(a.datas) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + exclude_binaries=True, + name='scenedetect', + debug=False, + strip=False, + upx=True, + console=True, + version='.version_info', + icon='pyscenedetect.ico') +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name='scenedetect') diff --git a/dist/windows_thirdparty.7z b/packaging/windows/thirdparty.7z similarity index 100% rename from dist/windows_thirdparty.7z rename to packaging/windows/thirdparty.7z diff --git a/pyproject.toml b/pyproject.toml index 8186012b..88f2f034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,17 +5,101 @@ # [ 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] -requires = ["setuptools"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" -[tool.ruff] -exclude = [ - "docs" +[project] +name = "scenedetect-core" +dynamic = ["version"] +description = "The detection pipeline for PySceneDetect (core library, minimal dependencies)." +readme = { file = "packaging/package-info.rst", content-type = "text/x-rst" } +license = "BSD-3-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Brandon Castellano", email = "brandon248@gmail.com" }] +keywords = ["video", "computer-vision", "analysis"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: Console :: Curses", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", + "Topic :: Multimedia :: Video :: Non-Linear Editor", + "Topic :: Utilities", +] +# OpenCV is required at runtime but intentionally NOT declared: any of the four +# opencv-python* variants satisfies the library for development installs. This root +# config (`scenedetect-core`) is used for local/dev installs only and is NOT +# published to PyPI (0.7.1 was published briefly and yanked - layering the published +# packages over a shared core dist is unsafe with pip; see +# https://scenedetect.com/issues/558). The published `scenedetect` and +# `scenedetect-headless` packages (packaging/variants/) ship the same code with a +# concrete OpenCV variant plus the CLI dependencies. +dependencies = [ + "numpy", ] + +[project.optional-dependencies] +opencv = ["opencv-python"] +opencv-headless = ["opencv-python-headless"] +pyav = ["av>=9.2"] +moviepy = ["moviepy"] +dev = [ + "av>=9.2", + # click 8.3.0 specifically is excluded per https://scenedetect.com/issues/521; 8.3.1+ are fine. + "click~=8.0,!=8.3.0", + "moviepy", + "opencv-python", + "platformdirs", + "pytest>=7.0", + "pytest-rerunfailures", + "tqdm", +] +docs = ["Sphinx==7.0.1", "sphinx-copybutton==0.5.2"] +website = ["mkdocs==1.5.2", "jinja2>=3.1.6"] + +[project.urls] +Homepage = "https://www.scenedetect.com" +Documentation = "https://www.scenedetect.com/docs/" +Source = "https://github.com/Breakthrough/PySceneDetect" +Issues = "https://github.com/Breakthrough/PySceneDetect/issues" + +# No [project.scripts]: the `scenedetect` console script is declared by the +# scenedetect/scenedetect-headless variants (packaging/variants/) so a core +# install stays library-only. Use `python -m scenedetect` from a core-only install. + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["scenedetect*"] + +[tool.setuptools.dynamic] +version = { attr = "scenedetect.__version__" } + +[tool.pytest.ini_options] +markers = [ + "release: opt-in release-validation tests; excluded by default (run with `pytest -m release`)", +] +addopts = "-m 'not release'" +filterwarnings = [ + "ignore:TODO.*Update caller to handle VFR:UserWarning", +] + +[tool.ruff] line-length = 100 indent-width = 4 @@ -31,23 +115,59 @@ select = [ "B", # pycodestyle "E", + "W", # Pyflakes "F", # isort "I", - # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # pyupgrade - #"UP", + "UP", # flake8-simplify - #"SIM", + "SIM", + # ruff-native checks + "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", - # TODO: Do not assign a `lambda` expression, use a `def` - "E731", ] fixable = ["ALL"] 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", "scripts", "packaging"] +exclude = [ + # Pyright built-in defaults + "**/node_modules", + "**/__pycache__", + "**/.*", + ".venv", + # Vendored third-party code + "scenedetect/_thirdparty", + # Release tests pull in extra deps (opentimelineio, psutil) that aren't in [dev] + "tests/release", + # User-local scripts (gitignored) + "scripts/local", +] + +# Modes: "off" | "basic" | "standard" | "strict". The 0.7 codebase is clean +# 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" + +# Analyze against the minimum supported Python so 3.11+-only APIs get flagged +# regardless of which interpreter runs pyright. +pythonVersion = "3.10" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index bb9dd91d..00000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# -# PySceneDetect Requirements -# -av>=9.2 -# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 -click~=8.0,<8.3.0 -numpy -opencv-python -platformdirs -pytest>=7.0 -tqdm diff --git a/requirements_headless.txt b/requirements_headless.txt deleted file mode 100644 index b9050ffa..00000000 --- a/requirements_headless.txt +++ /dev/null @@ -1,11 +0,0 @@ -# -# PySceneDetect Requirements for Headless Machines -# -av>=9.2 -# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 -click~=8.0,<8.3.0 -numpy -opencv-python-headless -platformdirs -pytest>=7.0 -tqdm diff --git a/scenedetect.cfg b/scenedetect.cfg index fd6241cd..d987435e 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -126,7 +126,7 @@ [detect-hash] # Threshold between 0.0 and 1.0 to set the relative difference between # hashes required to trigger a shot change. Lower values are more sensitive. -#threshold = 0.395 +#threshold = 0.35 # The ratio between 1 and 256 of how much low frequency information to keep. # Represents highest frequency which will pass the filter. 1 means keep all, @@ -135,7 +135,7 @@ # Size between 1 and 256 representing size of square of low frequency data to # use for the direct cosine transform (DCT). -#size = 16 +#size = 8 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s @@ -145,10 +145,10 @@ # Threshold between 0.0 to 1.0 to set the relative difference between Y # channel histograms (YUV) required to trigger a shot change. Lower values # are more sensitive. -#threshold = 0.05 +#threshold = 0.20 # Number of bins between 1 and 256 to use for the histogram. -#bins = 256 +#bins = 128 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s @@ -206,6 +206,11 @@ # Arguments to specify to ffmpeg for encoding. Quotes are not required. #args = -map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac +# Extend the first/last output clips to cover the full input video, even if +# `time -s/-e` limited the analysis window. Useful for keeping content outside +# the analyzed region attached to the adjacent split. +#expand = no + [save-images] # Folder to output videos. Overrides [global] output option. @@ -227,7 +232,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). @@ -344,6 +350,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/__init__.py b/scenedetect/__init__.py index 59fd44d6..f8b9ee73 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. # @@ -15,14 +15,14 @@ :class:`SceneManager `. """ -import typing as ty +import warnings 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", @@ -31,69 +31,81 @@ # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. # Note that order of importants is important! -from scenedetect.platform import init_logger # noqa: I001 +from scenedetect.platform import init_logger as init_logger # noqa: I001 from scenedetect.common import ( - FrameTimecode, - SceneList, - CutList, - CropRegion, - TimecodePair, - Interpolation, + FrameTimecode as FrameTimecode, + FrameRate as FrameRate, + SceneList as SceneList, + CutList as CutList, + CropRegion as CropRegion, + TimecodePair as TimecodePair, + TimecodeLike as TimecodeLike, + Interpolation as Interpolation, ) -from scenedetect.video_stream import VideoStream, VideoOpenFailure +from scenedetect.platform import StrPath as StrPath +from scenedetect.video_stream import VideoStream as VideoStream +from scenedetect.video_stream import VideoOpenFailure as VideoOpenFailure from scenedetect.output import ( - save_images, - split_video_ffmpeg, - split_video_mkvmerge, - is_ffmpeg_available, - is_mkvmerge_available, - write_scene_list, - write_scene_list_html, - PathFormatter, - VideoMetadata, - SceneMetadata, + save_images as save_images, + split_video_ffmpeg as split_video_ffmpeg, + split_video_mkvmerge as split_video_mkvmerge, + is_ffmpeg_available as is_ffmpeg_available, + is_mkvmerge_available as is_mkvmerge_available, + write_scene_list as write_scene_list, + write_scene_list_html as write_scene_list_html, + PathFormatter as PathFormatter, + VideoMetadata as VideoMetadata, + SceneMetadata as SceneMetadata, ) -from scenedetect.detector import SceneDetector +from scenedetect.detector import SceneDetector as SceneDetector from scenedetect.detectors import ( - ContentDetector, - AdaptiveDetector, - ThresholdDetector, - HistogramDetector, - HashDetector, + ContentDetector as ContentDetector, + AdaptiveDetector as AdaptiveDetector, + ThresholdDetector as ThresholdDetector, + HistogramDetector as HistogramDetector, + HashDetector as HashDetector, ) from scenedetect.backends import ( - AVAILABLE_BACKENDS, - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - VideoCaptureAdapter, + AVAILABLE_BACKENDS as AVAILABLE_BACKENDS, + VideoStreamCv2 as VideoStreamCv2, + VideoStreamAv as VideoStreamAv, + VideoStreamMoviePy as VideoStreamMoviePy, + VideoCaptureAdapter as VideoCaptureAdapter, + VideoStreamConcat as VideoStreamConcat, + SourceSpan as SourceSpan, ) -from scenedetect.stats_manager import StatsManager, StatsFileCorrupt +from scenedetect.stats_manager import StatsManager as StatsManager +from scenedetect.stats_manager import StatsFileCorrupt as StatsFileCorrupt from scenedetect.scene_manager import SceneManager # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.7-dev0" +__version__ = "0.7.1" init_logger() logger = getLogger("pyscenedetect") def open_video( - path: str, - framerate: ty.Optional[float] = None, + path: "StrPath | list[StrPath] | tuple[StrPath, ...]", + frame_rate: FrameRate | None = None, backend: str = "opencv", + framerate: float | None = None, **kwargs, ) -> VideoStream: """Open a video at the given path. If `backend` is specified but not available on the current system, OpenCV (`VideoStreamCv2`) will be used as a fallback. Arguments: - path: Path to video file to open. - framerate: Overrides detected framerate if set. + path: Path to video file to open. May also be a list of paths, in which case the videos + are concatenated and treated as a single continuous stream + (see :class:`VideoStreamConcat `). + frame_rate: Overrides detected frame rate if set. Takes precedence over `framerate`. backend: Name of specific backend to use, if possible. See :data:`scenedetect.backends.AVAILABLE_BACKENDS` for backends available on the current system. If the backend fails to open the video, OpenCV will be used as a fallback. + framerate: [DEPRECATED] Use `frame_rate` instead. Retained as a deprecated alias for + backwards compatibility; ignored when `frame_rate` is provided. kwargs: Optional named arguments to pass to the specified `backend` constructor for overriding backend-specific options. @@ -104,13 +116,26 @@ 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 + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) + if frame_rate is None: + frame_rate = framerate + # A list of paths is opened as a single concatenated stream. VideoStreamConcat handles + # backend selection/fallback internally, so this must come before the lookup below. + if isinstance(path, (list, tuple)): + return VideoStreamConcat(path, frame_rate, backend=backend, **kwargs) + 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] try: logger.debug("Opening video with %s...", backend_type.BACKEND_NAME) - return backend_type(path, framerate, **kwargs) + return backend_type(path, frame_rate, **kwargs) except VideoOpenFailure as ex: logger.warning("Failed to open video with %s: %s", backend_type.BACKEND_NAME, str(ex)) if backend == VideoStreamCv2.BACKEND_NAME: @@ -122,7 +147,7 @@ def open_video( backend_type = VideoStreamCv2 logger.warning("Trying another backend: %s", backend_type.BACKEND_NAME) try: - return backend_type(path, framerate) + return backend_type(path, frame_rate) except VideoOpenFailure as ex: logger.debug("Failed to open video: %s", str(ex)) if last_error is None: @@ -133,18 +158,21 @@ def open_video( def detect( - video_path: str, + video_path: "StrPath | list[StrPath] | tuple[StrPath, ...]", detector: SceneDetector, - stats_file_path: ty.Optional[str] = None, + stats_file_path: StrPath | 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: TimecodeLike | None = None, + end_time: TimecodeLike | None = None, start_in_scene: bool = False, + backend: str = "opencv", ) -> SceneList: """Perform scene detection on a given video `path` using the specified `detector`. Arguments: - video_path: Path to input video (absolute or relative to working directory). + video_path: Path to input video (absolute or relative to working directory). May also + be a list of paths, in which case the videos are concatenated and treated as a + single continuous stream. detector: A `SceneDetector` instance (see :mod:`scenedetect.detectors` for a full list of detectors). stats_file_path: Path to save per-frame metrics to for statistical analysis or to @@ -159,6 +187,10 @@ def detect( will contain a single scene spanning the entire video (instead of no scenes). When detecting fades with `ThresholdDetector`, the beginning portion of the video will always be included until the first fade-out event is detected. + backend: Name of the backend to use for video decoding. See + :data:`scenedetect.backends.AVAILABLE_BACKENDS` for backends available on the + current system. Defaults to OpenCV; falls back to OpenCV if the requested backend + is unavailable or fails to open the video. Returns: List of scenes as pairs of (start, end) :class:`FrameTimecode` objects. @@ -169,12 +201,10 @@ def detect( ValueError: `start_time` or `end_time` are incorrectly formatted. TypeError: `start_time` or `end_time` are invalid types. """ - video = open_video(video_path) + video = open_video(video_path, backend=backend) 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) @@ -182,8 +212,8 @@ 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: + 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/__main__.py b/scenedetect/__main__.py index 5a8f7e4c..f97bc320 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. # @@ -17,7 +17,7 @@ from scenedetect._cli import scenedetect from scenedetect._cli.context import CliContext from scenedetect._cli.controller import run_scenedetect -from scenedetect.platform import FakeTqdmLoggingRedirect, logging_redirect_tqdm +from scenedetect.platform import DEBUG_MODE, FakeTqdmLoggingRedirect, logging_redirect_tqdm def main(): @@ -46,14 +46,14 @@ def main(): run_scenedetect(context) except KeyboardInterrupt: logger.info("Stopped.") - if __debug__: + if DEBUG_MODE: raise + raise SystemExit(1) from None except BaseException as ex: - if __debug__: + if DEBUG_MODE: raise - else: - logger.critical("ERROR: Unhandled exception:", exc_info=ex) - raise SystemExit(1) from None + logger.critical("ERROR: Unhandled exception:", exc_info=ex) + raise SystemExit(1) from ex if __name__ == "__main__": diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index a0c639d2..19c78d00 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. # @@ -22,12 +22,11 @@ import logging import os import os.path -import typing as ty -from copy import deepcopy +from copy import copy 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 +34,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,20 +47,32 @@ ) 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/ 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/ ]. @@ -95,7 +107,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 +129,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 +210,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", @@ -216,12 +229,14 @@ def print_command_help(ctx: click.Context, command: click.Command): help="Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.", ) @click.option( + "--frame-rate", "--framerate", "-f", + "frame_rate", metavar="FPS", type=click.FLOAT, default=None, - help="Override framerate with value as frames/sec.", + help="Override frame rate with value as frames/sec.", ) @click.option( "--min-scene-len", @@ -229,24 +244,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 +272,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 +291,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 +301,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 +311,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 +333,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: str | None, + output: str | None, + stats: str | None, + config: str | None, + frame_rate: 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: str | None, quiet: bool, ): ctx = ctx.obj @@ -334,7 +355,7 @@ def scenedetect( ctx.handle_options( input_path=input, output=output, - framerate=framerate, + frame_rate=frame_rate, stats_file=stats, frame_skip=frame_skip, min_scene_len=min_scene_len, @@ -353,7 +374,9 @@ def scenedetect( def add_hidden_alias(command: click.Command, alias: str): """Adds a copy of `command` that can be invoked under the name `alias`.""" - hidden_command = deepcopy(command) + # Shallow copy: deepcopy fails on Python 3.10 + click >=8.3 because click's internal + # `Sentinel` enum values are not deepcopy-safe. + hidden_command = copy(command) hidden_command.hidden = True scenedetect.add_command(hidden_command, alias) @@ -368,22 +391,27 @@ 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: 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("") - 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() @@ -393,7 +421,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 +478,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) @@ -502,13 +530,11 @@ 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.%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 +542,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 +561,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 +584,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 +592,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 +631,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 +641,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 +651,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 +679,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 +699,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) @@ -704,33 +738,30 @@ 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.%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", "-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.%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 +779,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) @@ -783,25 +814,22 @@ 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.%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", "-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.%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 +849,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) @@ -852,39 +880,36 @@ 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 " - "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( "--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.%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", "-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 " - "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,21 +969,23 @@ 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) 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: 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( @@ -983,32 +1010,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,19 +1047,20 @@ 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: str | 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.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) @@ -1036,6 +1068,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), @@ -1067,8 +1100,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 +1110,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 +1120,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 +1130,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 +1140,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: str | None, + filename: str | None, + no_output_file: bool | None, + quiet: bool | None, + skip_cuts: bool | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1156,8 +1195,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 +1205,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,36 +1215,37 @@ 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", "-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") ), - 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")), ) @click.option( "--preset", @@ -1211,8 +1253,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,34 +1264,47 @@ 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.option( + "--expand", + is_flag=True, + flag_value=True, + default=False, + help="Extend the first/last output clips to cover the full input video, even if `time -s/-e` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split.{}".format( + USER_CONFIG.get_help_string("split-video", "expand") + ), ) @click.pass_context def split_video_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], + output: str | None, + filename: str | 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, + expand: bool, ): ctx = ctx.obj 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") @@ -1270,20 +1324,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 @@ -1311,6 +1365,7 @@ def split_video_command( "output": ctx.config.get_value("split-video", "output", output), "show_output": not ctx.config.get_value("split-video", "quiet", quiet), "ffmpeg_args": args, + "expand": ctx.config.get_value("split-video", "expand", expand), } ctx.add_command(cli_commands.split_video, split_video_args) @@ -1333,8 +1388,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 +1398,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 +1408,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 +1434,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,17 +1451,19 @@ 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", "--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).{}".format( + USER_CONFIG.get_help_string("save-images", "frame-margin") + ), ) @click.option( "--scale", @@ -1409,8 +1471,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 +1481,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,27 +1491,29 @@ 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: str | None = None, + filename: str | 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[int] = 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) + assert ctx.video_stream is not None if "://" in ctx.video_stream.path: error_str = "\nThe save-images command is incompatible with URLs." @@ -1478,7 +1544,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 +1584,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 +1592,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 +1600,37 @@ 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.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, - filename: ty.Optional[ty.AnyStr], - title: ty.Optional[ty.AnyStr], - reel: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: str | None, + title: str | None, + reel: str | None, + output: str | None, + start_timecode: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1560,6 +1640,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) @@ -1577,15 +1658,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 +1675,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: str | None, + output: str | None, + disable_shift: bool | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1614,27 +1697,26 @@ 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.{}".format(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"), + help="Format to export. TYPE must be one of: {}.{}".format( + ", ".join(CHOICE_MAP["save-fcp"]["format"]), + USER_CONFIG.get_help_string("save-fcp", "format"), ), ) @click.option( @@ -1642,25 +1724,26 @@ 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-xml", "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_xml_command( +def save_fcp_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - format: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: str | None, + format: str | None, + output: str | None, ): 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. @@ -1675,7 +1758,7 @@ def save_xml_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 +1766,16 @@ def save_xml_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 +1792,9 @@ def save_xml_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: str | None, + name: str | None, + output: str | None, audio: bool, no_audio: bool, ): @@ -1757,7 +1841,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 6813caa8..740f38b3 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. # @@ -15,33 +15,29 @@ 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 ( CutList, Interpolation, SceneList, + expand_scenes_to_bounds, ) logger = logging.getLogger("pyscenedetect") @@ -58,6 +54,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 @@ -90,6 +87,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, @@ -97,7 +95,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) @@ -120,6 +118,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( @@ -155,14 +154,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) ] ), @@ -184,7 +176,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, @@ -194,6 +186,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, @@ -224,9 +217,22 @@ def split_video( output: str, show_output: bool, ffmpeg_args: str, + expand: bool, ): """Handles the `split-video` command.""" del cuts # split-video only uses scenes. + assert context.video_stream is not None + + if expand and scenes: + video_duration = context.video_stream.duration + if video_duration is None: + logger.warning("Cannot --expand: video duration is unavailable for this stream.") + else: + scenes = expand_scenes_to_bounds( + scenes, + start=context.video_stream.base_timecode, + end=video_duration, + ) if use_mkvmerge: name_format = name_format.removesuffix("-$SCENE_NUMBER") @@ -270,217 +276,65 @@ def save_edl( output: str, title: str, reel: str, + start_timecode: str | None, ): """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. + assert context.video_stream is not None 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 - - duration = scenes[-1][1] - scenes[0][0] - ElementTree.SubElement(sequence, "duration").text = f"{duration.frame_num}" - - rate = ElementTree.SubElement(sequence, "rate") - ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) - ElementTree.SubElement(rate, "ntsc").text = "False" - - timecode = ElementTree.SubElement(sequence, "timecode") - tc_rate = ElementTree.SubElement(timecode, "rate") - ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate) - ElementTree.SubElement(tc_rate, "ntsc").text = "False" - ElementTree.SubElement(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"{context.video_stream.frame_rate}") - ) - # TODO: Are these supposed to be frame numbers or another format? - ElementTree.SubElement(clip, "start").text = str(start.frame_num) - ElementTree.SubElement(clip, "end").text = str(end.frame_num) - ElementTree.SubElement(clip, "in").text = str(start.frame_num) - ElementTree.SubElement(clip, "out").text = str(end.frame_num) - - 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, + start_timecode=start_timecode, ) - 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 + assert context.video_stream is not None - 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}") @@ -494,92 +348,20 @@ 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 = 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": float((end - start).frame_num), - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": float(start.frame_num), - }, - }, - "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() - ], - }, - } - + assert context.video_stream is not None + 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 ee851da8..26787080 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. # @@ -23,17 +23,19 @@ from configparser import Error as ConfigParserError from enum import Enum +import click from platformdirs import user_config_dir from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter from scenedetect.detectors import ContentDetector from scenedetect.output.video import _DEFAULT_FFMPEG_ARGS +from scenedetect.platform import DEBUG_MODE from scenedetect.scene_manager import Interpolation PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] -LogMessage = ty.Tuple[int, str] +LogMessage = tuple[int, str] class OptionParseFailure(Exception): @@ -75,13 +77,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 +101,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,19 +113,26 @@ 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 + @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: @@ -134,21 +143,24 @@ 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 class CropValue(ValidatedValue): """Validator for crop region defined as X0 Y0 X1 Y1.""" - _IGNORE_CHARS = [",", "/", "(", ")"] + _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" - def __init__(self, value: ty.Optional[ty.Union[str, ty.Tuple[int, int, int, int]]] = 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} @@ -165,11 +177,14 @@ 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] | None: return self._crop def __str__(self) -> str: - return "[%d, %d], [%d, %d]" % self.value + if self._crop is None: + return "(none)" + x0, y0, x1, y1 = self._crop + return f"[{x0}, {y0}], [{x1}, {y1}]" @staticmethod def from_config(config_value: str, default: "CropValue") -> "CropValue": @@ -182,10 +197,10 @@ 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: ty.Union[str, ContentDetector.Components]): + def __init__(self, value: str | ContentDetector.Components): if isinstance(value, ContentDetector.Components): self._value = value else: @@ -202,7 +217,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": @@ -219,25 +234,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": @@ -282,7 +299,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): @@ -301,27 +323,31 @@ 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.") -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""" -ConfigValue = ty.Union[bool, int, float, str] -ConfigDict = ty.Dict[str, ty.Dict[str, ConfigValue]] +# `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: 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 @@ -353,13 +379,13 @@ class XmlFormat(Enum): "detect-hash": { "min-scene-len": TimecodeValue(0), "lowpass": RangeValue(2, min_val=1, max_val=256), - "size": RangeValue(16, min_val=1, max_val=256), - "threshold": RangeValue(0.395, min_val=0.0, max_val=1.0), + "size": RangeValue(8, min_val=1, max_val=256), + "threshold": RangeValue(0.35, min_val=0.0, max_val=1.0), }, "detect-hist": { "min-scene-len": TimecodeValue(0), - "threshold": RangeValue(0.05, min_val=0.0, max_val=1.0), - "bins": RangeValue(256, min_val=1, max_val=256), + "threshold": RangeValue(0.20, min_val=0.0, max_val=1.0), + "bins": RangeValue(128, min_val=1, max_val=256), }, "detect-threshold": { "add-last-scene": True, @@ -399,6 +425,7 @@ class XmlFormat(Enum): "filename": "$VIDEO_NAME.edl", "output": None, "reel": "AX", + "start-timecode": None, "title": "$VIDEO_NAME", }, "save-html": { @@ -412,7 +439,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, @@ -433,14 +460,15 @@ 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, }, "split-video": { "args": _DEFAULT_FFMPEG_ARGS, "copy": False, + "expand": False, "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, "mkvmerge": False, @@ -454,7 +482,7 @@ class XmlFormat(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], }, @@ -480,8 +508,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": [ @@ -501,14 +529,14 @@ class XmlFormat(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: @@ -533,12 +561,12 @@ def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessa ) ) 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( ( @@ -549,7 +577,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) @@ -568,34 +596,35 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. 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 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,28 +641,25 @@ 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 # 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 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 @@ -642,22 +668,24 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. # 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 [%s] option '%s': %s. Must be one of: %s." - % ( - 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 @@ -669,16 +697,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: list[LogMessage], 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 +721,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 @@ -713,23 +741,23 @@ def get_init_log(self): self._init_log = [] return init_log - def _log(self, log_level, log_str): + def _log(self, log_level: int, log_str: str) -> None: self._init_log.append((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._log(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._log(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. if not os.path.exists(CONFIG_FILE_PATH): - self._init_log.append((logging.DEBUG, "User config file not found.")) + self._log(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._log(logging.INFO, f"Loading user config file:\n {path}") # Try to load and parse the config file at `path`. config = ConfigParser() try: @@ -737,14 +765,14 @@ def _load_from_disk(self, path=None): config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) except (ConfigParserError, OSError) as ex: - if __debug__: + if DEBUG_MODE: raise raise ConfigLoadFailure(self._init_log, reason=ex) from None # At this point the config file syntax is correct, but we need to still validate # the parsed options (i.e. that the options have valid values). (config, logs) = _parse_config(config) for verbosity, message in logs: - self._init_log.append((verbosity, message)) + self._log(verbosity, message) if config is None: raise ConfigLoadFailure(self._init_log) self._config = config @@ -757,25 +785,29 @@ def get_value( self, command: str, option: str, - override: ty.Optional[ConfigValue] = 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] + 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: 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 +824,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: {CONFIG_MAP[command][option]!s}]" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index b23287e3..e5cebb0f 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. # @@ -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, @@ -34,8 +34,8 @@ ThresholdDetector, ) 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.platform import DEBUG_MODE, init_logger +from scenedetect.scene_manager import SceneManager from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream @@ -81,56 +81,75 @@ 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.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.frame_skip: int = None + 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 = 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.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 # 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.") + 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: ty.Optional[str], correct_pts: bool = False) -> FrameTimecode: + 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. @@ -142,11 +161,13 @@ def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> 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" @@ -154,22 +175,22 @@ 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], - framerate: float, - stats_file: ty.Optional[ty.AnyStr], - 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], + input_path: str | None, + output: str | None, + frame_rate: 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.Optional[ty.AnyStr], - config: ty.Optional[ty.AnyStr], - stats: ty.Optional[ty.AnyStr], - verbosity: ty.Optional[str], + logfile: str | None, + config: str | None, + stats: str | 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 @@ -185,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() @@ -206,7 +228,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__) @@ -236,7 +260,7 @@ def handle_options( return # Load the input video to obtain a time base for parsing timecodes. - self._open_video_stream(input_path, framerate, backend) + self._open_video_stream(input_path, frame_rate, backend) self.output = self.config.get_value("global", "output", output) if self.output: @@ -282,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 @@ -295,6 +319,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) @@ -312,29 +337,21 @@ def handle_options( def get_detect_content_params( self, - threshold: ty.Optional[float] = 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]: + threshold: float | None = 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, + 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"): - 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 + min_scene_len_frames = self._resolve_min_scene_len("detect-content", min_scene_len) if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: - if __debug__: + if DEBUG_MODE: raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None @@ -343,38 +360,30 @@ 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), } def get_detect_adaptive_params( self, - threshold: ty.Optional[float] = None, - min_content_val: ty.Optional[float] = None, - frame_window: ty.Optional[int] = 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]: + threshold: float | None = None, + min_content_val: float | None = None, + frame_window: int | None = 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, + ) -> 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"): - 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 + min_scene_len_frames = self._resolve_min_scene_len("detect-adaptive", min_scene_len) if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: - if __debug__: + if DEBUG_MODE: raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None @@ -386,81 +395,57 @@ 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), } def get_detect_threshold_params( self, - threshold: ty.Optional[float] = None, - fade_bias: ty.Optional[float] = None, - add_last_scene: bool = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + threshold: float | None = None, + fade_bias: float | None = 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.""" - 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"): - 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 + 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), } 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: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hist", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).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), } 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: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hash", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).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), } @@ -471,9 +456,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: str | None = None, ): """Setup logging based on CLI args and user configuration settings.""" if quiet is not None: @@ -504,22 +489,22 @@ def _initialize_logging( def _open_video_stream( self, - input_path: ty.AnyStr, - framerate: ty.Optional[float], - backend: ty.Optional[str], + input_path: str, + frame_rate: float | None, + backend: str | None, ): if "%" in input_path and backend != "opencv": raise click.BadParameter( "The OpenCV backend (`--backend opencv`) must be used to process image sequences.", param_hint="-i/--input", ) - if framerate is not None and framerate < MAX_FPS_DELTA: - raise click.BadParameter("Invalid framerate specified!", param_hint="-f/--framerate") + if frame_rate is not None and frame_rate < MAX_FPS_DELTA: + raise click.BadParameter("Invalid frame rate specified!", param_hint="-f/--frame-rate") try: 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", ) @@ -527,7 +512,7 @@ def _open_video_stream( if backend == "pyav": self.video_stream = open_video( path=input_path, - framerate=framerate, + frame_rate=frame_rate, backend=backend, threading_mode=self.config.get_value("backend-pyav", "threading-mode"), suppress_output=self.config.get_value("backend-pyav", "suppress-output"), @@ -535,7 +520,7 @@ def _open_video_stream( elif backend == "opencv": self.video_stream = open_video( path=input_path, - framerate=framerate, + frame_rate=frame_rate, backend=backend, max_decode_attempts=self.config.get_value( "backend-opencv", "max-decode-attempts" @@ -545,34 +530,38 @@ def _open_video_stream( else: self.video_stream = open_video( path=input_path, - framerate=framerate, + frame_rate=frame_rate, backend=backend, ) + duration = self.video_stream.duration + duration_str = f"{duration} ({duration.frame_num} frames)" if duration else "unknown" + rate = self.video_stream.frame_rate 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)""") + Frame rate: {float(rate):.3f} ({rate.numerator}/{rate.denominator}) + Duration: {duration_str}""") except FrameRateUnavailable as ex: - if __debug__: + if DEBUG_MODE: raise raise click.BadParameter( - "Failed to obtain framerate for input video. Manually specify framerate with the" - " -f/--framerate option, or try re-encoding the file.", + "Failed to obtain frame rate for input video. Manually specify frame rate with the" + " -f/--frame-rate option, or try re-encoding the file.", param_hint="-i/--input", ) from ex except VideoOpenFailure as ex: - if __debug__: + if DEBUG_MODE: 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__: + if DEBUG_MODE: raise raise click.BadParameter( - "Input error:\n\n\t%s\n" % str(ex), 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 be7ac8d2..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. # @@ -15,7 +15,6 @@ import logging import os import time -import typing as ty import warnings from scenedetect._cli.context import CliContext @@ -82,20 +81,32 @@ 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: + 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 -def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: +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: @@ -153,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) @@ -162,10 +174,13 @@ 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 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) @@ -180,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 @@ -194,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/_fan_out.py b/scenedetect/_fan_out.py new file mode 100644 index 00000000..0d8883a3 --- /dev/null +++ b/scenedetect/_fan_out.py @@ -0,0 +1,244 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tee one VideoStream into N consumer streams sharing a single decode. + +Used by the benchmark sweep harness to amortize video decoding across multiple detector +configurations running in parallel: one source decode feeds N consumer streams, each +read by an independent detection thread. The source is paced by the slowest consumer +(blocking ``put`` into bounded per-consumer queues), so peak memory is bounded by +``n * prefetch`` frames. + +Internal API (underscore-prefixed module). Not part of the public surface. +""" + +from __future__ import annotations + +import contextlib +import queue +import threading +from fractions import Fraction + +import numpy as np + +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.video_stream import SeekError, VideoStream + +_EOF = object() +"""Sentinel placed on each consumer queue when the source reaches end-of-stream.""" + + +class FanOutVideoStream: + """Drives one source :class:`VideoStream` and fans frames out to N consumer streams. + + Usage:: + + source = open_video("video.mp4") + fan = FanOutVideoStream(source, n=4) + fan.start() + try: + for i in range(4): + threading.Thread(target=worker, args=(fan.stream(i),)).start() + # ... join workers ... + finally: + fan.close() + + The wrapper owns one background reader thread. Each ``stream(i)`` handle is a + forward-only :class:`VideoStream` that reads from its own queue. ``seek``/``reset`` + on a consumer raise :class:`SeekError` -- to re-run a sweep over the same source, + call ``source.reset()`` on the underlying stream and build a fresh + ``FanOutVideoStream`` for the next chunk. + """ + + def __init__(self, source: VideoStream, n: int, prefetch: int = 4): + """ + Arguments: + source: Already-opened ``VideoStream`` to read from. + n: Number of consumer streams to expose. Must be >= 1. + prefetch: Per-consumer queue depth. ``0`` is rendezvous (every frame waits + for every consumer to take it); 4-8 absorbs jitter between consumers + at the cost of up to ``n * prefetch`` resident frames. + """ + if n < 1: + raise ValueError("n must be at least 1") + if prefetch < 0: + raise ValueError("prefetch must be >= 0") + self._source = source + # queue.Queue(maxsize=0) means unbounded, which would defeat back-pressure. + # prefetch=0 therefore maps to a 1-deep buffer (shallow, not strict rendezvous). + qsize = prefetch if prefetch > 0 else 1 + self._queues: list[queue.Queue] = [queue.Queue(maxsize=qsize) for _ in range(n)] + self._consumers: list[_FanOutConsumer] = [_FanOutConsumer(self, i) for i in range(n)] + self._stop = threading.Event() + self._reader: threading.Thread | None = None + self._started = False + self._closed = False + self._reader_exc: BaseException | None = None + + @property + def num_consumers(self) -> int: + """Number of consumer streams exposed by this wrapper.""" + return len(self._consumers) + + def stream(self, i: int) -> VideoStream: + """Return the i-th consumer ``VideoStream``.""" + return self._consumers[i] + + def start(self) -> None: + """Spawn the reader thread. Idempotent; subsequent calls are no-ops.""" + if self._started: + return + self._started = True + self._reader = threading.Thread( + target=self._read_loop, name="FanOutVideoStream-reader", daemon=True + ) + self._reader.start() + + def abort(self) -> None: + """Signal the reader to stop. Called by consumers on EOF/error to unblock the source.""" + self._stop.set() + # Drain queues so a put() blocked by maxsize wakes up. + for q in self._queues: + with contextlib.suppress(queue.Empty): + while True: + q.get_nowait() + + def close(self) -> None: + """Stop the reader thread and release resources. Idempotent.""" + if self._closed: + return + self._closed = True + self.abort() + if self._reader is not None: + self._reader.join(timeout=5.0) + + def _read_loop(self) -> None: + try: + while not self._stop.is_set(): + frame = self._source.read() + if frame is False: + break + # Block per-consumer; slowest consumer paces the source. + for q in self._queues: + while not self._stop.is_set(): + try: + q.put(frame, timeout=0.1) + break + except queue.Full: + continue + if self._stop.is_set(): + return + except BaseException as e: + self._reader_exc = e + finally: + # Sentinel must reach every consumer or its blocking read() deadlocks. On normal + # EOF the put must respect back-pressure (a full queue still holds undelivered + # frames); only once an abort is in progress may pending frames be dropped to + # force the sentinel through. + for q in self._queues: + while True: + try: + q.put(_EOF, timeout=0.1) + break + except queue.Full: + if self._stop.is_set(): + with contextlib.suppress(queue.Empty): + q.get_nowait() + + +class _FanOutConsumer(VideoStream): + """One consumer-side handle exposed by :class:`FanOutVideoStream`. + + Forwards constant metadata (path, frame_rate, frame_size, etc.) to the source. + Maintains its own ``frame_number`` / ``position`` -- both advance only when this + consumer calls ``read()``, independent of the source's position or sibling + consumers. + """ + + BACKEND_NAME = "fan_out" + + def __init__(self, parent: FanOutVideoStream, index: int): + self._parent = parent + self._index = index + self._frame_number = 0 + self._eof = False + + @property + def path(self) -> str: + return self._parent._source.path + + @property + def name(self) -> str: + return self._parent._source.name + + @property + def is_seekable(self) -> bool: + return False + + @property + def frame_rate(self) -> Fraction: + return self._parent._source.frame_rate + + @property + def duration(self) -> FrameTimecode | None: + return self._parent._source.duration + + @property + def frame_size(self) -> tuple[int, int]: + return self._parent._source.frame_size + + @property + def aspect_ratio(self) -> float: + return self._parent._source.aspect_ratio + + @property + def decode_failures(self) -> int: + return self._parent._source.decode_failures + + @property + def frame_number(self) -> int: + return self._frame_number + + @property + def position(self) -> FrameTimecode: + # Mirrors VideoStream contract: "frame 1 corresponds to presentation time 0; + # returns 0 even if frame_number is 1." + n = max(0, self._frame_number - 1) + return FrameTimecode(timecode=n, fps=self.frame_rate) + + @property + def position_ms(self) -> float: + if self._frame_number == 0: + return 0.0 + fps = self.frame_rate + return float(1000 * (self._frame_number - 1) * fps.denominator) / float(fps.numerator) + + def read(self, decode: bool = True) -> np.ndarray | bool: + if self._eof: + return False + item = self._parent._queues[self._index].get() + if item is _EOF: + self._eof = True + if self._parent._reader_exc is not None: + raise self._parent._reader_exc + return False + self._frame_number += 1 + # The source already decoded the frame; decode=False just suppresses returning it. + if not decode: + return True + return item # type: ignore[return-value] + + def reset(self) -> None: + raise SeekError("FanOutVideoStream consumers are forward-only; reset the source instead.") + + def seek(self, target: TimecodeLike) -> None: + del target + raise SeekError("FanOutVideoStream consumers are forward-only; seeking is not supported.") 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/_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..bad4b972 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. # @@ -50,6 +50,14 @@ In both examples above, the resulting ``video`` can be used with :meth:`SceneManager.detect_scenes() `. +Multiple videos can be opened as one continuous stream by passing a list of paths to +:func:`open_video`, which returns a +:class:`VideoStreamConcat `: + +.. code:: python + + video = open_video(["part1.mp4", "part2.mp4"]) + =============================================================== Devices / Cameras / Pipes =============================================================== @@ -83,24 +91,25 @@ # 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 +from scenedetect.backends.concat import SourceSpan as SourceSpan +from scenedetect.backends.concat import VideoStreamConcat as VideoStreamConcat +from scenedetect.backends.opencv import VideoCaptureAdapter as VideoCaptureAdapter +from scenedetect.backends.opencv import VideoStreamCv2 as VideoStreamCv2 try: - from scenedetect.backends.pyav import VideoStreamAv + from scenedetect.backends.pyav import VideoStreamAv as VideoStreamAv except ImportError: VideoStreamAv = None try: - from scenedetect.backends.moviepy import VideoStreamMoviePy + from scenedetect.backends.moviepy import VideoStreamMoviePy as VideoStreamMoviePy except ImportError: VideoStreamMoviePy = None # 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, @@ -114,5 +123,5 @@ """All available backends that :func:`scenedetect.open_video` can consider for the `backend` parameter. These backends must support construction with the following signature: - BackendType(path: str, framerate: ty.Optional[float]) + BackendType(path: str, frame_rate: ty.Optional[float | Fraction]) """ diff --git a/scenedetect/backends/concat.py b/scenedetect/backends/concat.py new file mode 100644 index 00000000..af335a2e --- /dev/null +++ b/scenedetect/backends/concat.py @@ -0,0 +1,387 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""``scenedetect.backends.concat`` Module + +:class:`VideoStreamConcat` presents multiple videos as a single, contiguous +:class:`VideoStream ` with a monotonic PTS-based +timeline. Frames are decoded through any available backend, so the concatenation logic is +backend-agnostic. The easiest way to construct one is by passing a list of paths to +:func:`scenedetect.open_video`: + +.. code:: python + + from scenedetect import open_video + video = open_video(["part1.mp4", "part2.mp4"]) + +The resulting stream can be used anywhere a single-video stream can, e.g. with a +:class:`SceneManager `. All videos must have the same +resolution. Framerates may differ, in which case reported frame numbers may be inaccurate - +use `position` for accurate PTS-based timing. + +:meth:`VideoStreamConcat.map_span` maps a span of the global timeline back to per-source local +times (e.g. for use as ffmpeg `-ss`/`-t` arguments). +""" + +import bisect +import logging +import typing as ty +from dataclasses import dataclass +from fractions import Fraction +from pathlib import Path + +import numpy as np + +from scenedetect.common import FrameRate, FrameTimecode, Timecode, TimecodeLike +from scenedetect.platform import StrPath +from scenedetect.video_stream import VideoOpenFailure, VideoStream + +logger = logging.getLogger("pyscenedetect") + +_GLOBAL_TIME_BASE = Fraction(1, 1000000) +"""Time base used for the global (concatenated) timeline.""" + +FRAMERATE_DELTA_TOLERANCE: float = 0.1 +"""Tolerance in frames/sec above which a framerate mismatch between inputs is warned about.""" + + +@dataclass(frozen=True) +class _SourceMetadata: + """Declared metadata for one input video, probed via the child backend without decoding.""" + + path: Path + frame_size: tuple[int, int] + frame_rate: Fraction + duration: Fraction + """Declared duration of the video in seconds (exact rational value). May be inaccurate; + the global timeline is corrected once the actual end of the source is reached.""" + frames: int + """Declared number of frames in the video. May be inaccurate.""" + aspect_ratio: float + + +@dataclass(frozen=True) +class SourceSpan: + """Portion of a single input video covered by a time span on the global timeline of a + :class:`VideoStreamConcat`. Local times are relative to the start of that video, directly + usable as seek targets or ffmpeg `-ss`/`-t` values.""" + + source_index: int + path: Path + local_start: FrameTimecode + local_end: FrameTimecode + + +def _exact_seconds(timecode: FrameTimecode) -> Fraction: + """Time represented by `timecode` as an exact rational number of seconds.""" + return Fraction(timecode.pts) * timecode.time_base + + +class VideoStreamConcat(VideoStream): + """Concatenates multiple videos into a single, contiguous video stream with a + monotonic PTS-based global timeline. + + The concatenation logic is backend-agnostic: frames are read through any PySceneDetect + `VideoStream` backend, selected by name (default `opencv`). For the most accurate seam + timing, use `backend="pyav"` if available. + + Raises: + VideoOpenFailure: Failed to open a video, or video parameters don't match. + """ + + BACKEND_NAME = "concat" + + def __init__( + self, + paths: ty.Sequence[StrPath], + frame_rate: FrameRate | None = None, + backend: str = "opencv", + **kwargs, + ): + """Open a list of videos as one continuous stream. + + Arguments: + paths: List of paths of the videos to concatenate, in playback order. + frame_rate: If set, overrides the detected frame rate of every input. + backend: Name of the backend to decode each input with (see + :data:`scenedetect.backends.AVAILABLE_BACKENDS`). Falls back to OpenCV if + unavailable. + kwargs: Optional named arguments to pass to every child backend constructor. + + Raises: + OSError: A file could not be found or access was denied. + VideoOpenFailure: A video could not be opened, or resolutions don't match. + """ + assert paths + super().__init__() + # Import here to avoid a circular import (scenedetect.backends imports this module). + from scenedetect.backends import AVAILABLE_BACKENDS + + backend = backend.lower() + if backend not in AVAILABLE_BACKENDS: + logger.warning("Backend %s not available, falling back to opencv.", backend) + backend = "opencv" + self._backend_type: type = AVAILABLE_BACKENDS[backend] + self._paths: list[Path] = [Path(path) for path in paths] + self._frame_rate_override = frame_rate + self._backend_kwargs = kwargs + + # Probe all inputs up front for validation and metadata, then only keep one source + # open at a time for decoding. The handle probed for the first source is kept as the + # initial decode source to avoid re-opening it. + self._sources: list[_SourceMetadata] = [] + first_cap: VideoStream | None = None + for index in range(len(self._paths)): + cap = self._open_source(index) + duration = cap.duration + declared_seconds = _exact_seconds(duration) if duration is not None else Fraction(0) + self._sources.append( + _SourceMetadata( + path=self._paths[index], + frame_size=cap.frame_size, + frame_rate=cap.frame_rate, + duration=declared_seconds, + frames=duration.frame_num if duration is not None else 0, + aspect_ratio=cap.aspect_ratio, + ) + ) + if index == 0: + first_cap = cap + self._validate_sources() + + # Global start time of each source in exact rational seconds. Has one extra entry at + # the end holding the total (declared) duration. Values after the current source are + # estimates from declared durations, and are corrected once the actual end of each + # source is reached during decode. + self._offsets: list[Fraction] = [Fraction(0)] + for source in self._sources: + self._offsets.append(self._offsets[-1] + source.duration) + + self._index: int = 0 + self._frames_prior: int = 0 + self._decode_failures_prior: int = 0 + assert first_cap is not None + self._cap: VideoStream = first_cap + + # + # Concatenation Logic + # + + def _validate_sources(self): + first = self._sources[0] + for source in self._sources[1:]: + logger.debug( + "Appending video %s (%d x %d at %2.3f FPS).", + source.path.name, + source.frame_size[0], + source.frame_size[1], + float(source.frame_rate), + ) + if source.frame_size != first.frame_size: + raise VideoOpenFailure( + f"Video resolutions must match to be concatenated: {source.path.name} is " + f"{source.frame_size[0]} x {source.frame_size[1]}, expected " + f"{first.frame_size[0]} x {first.frame_size[1]}." + ) + if abs(float(source.frame_rate) - float(first.frame_rate)) > FRAMERATE_DELTA_TOLERANCE: + logger.warning( + "Framerate of %s does not match the first input. Timing is based on " + "presentation timestamps, but reported frame numbers may be inaccurate.", + source.path.name, + ) + + def _open_source(self, index: int) -> VideoStream: + return self._backend_type( + str(self._paths[index]), self._frame_rate_override, **self._backend_kwargs + ) + + def _child_position_seconds(self) -> Fraction: + """Position of the current source as exact rational seconds (local timeline).""" + return _exact_seconds(self._cap.position) + + def _finish_current_source(self): + """Correct the declared offset of the next source now that the actual end of the + current source is known, guaranteeing strictly monotonic PTS across the seam even + when the declared duration is inaccurate.""" + self._decode_failures_prior += self._cap.decode_failures + self._frames_prior += self._cap.frame_number + actual_end = ( + self._offsets[self._index] + + self._child_position_seconds() + + Fraction(1) / self._cap.frame_rate + ) + declared_end = self._offsets[self._index + 1] + if actual_end > declared_end: + delta = actual_end - declared_end + for i in range(self._index + 1, len(self._offsets)): + self._offsets[i] += delta + + def read(self, decode: bool = True) -> np.ndarray | bool: + """Read/decode the next frame. Returns False when all inputs have been processed.""" + while True: + result = self._cap.read(decode=decode) + if result is not False: + return result + if (self._index + 1) >= len(self._paths): + logger.debug("No more input to process.") + return False + self._finish_current_source() + self._index += 1 + logger.debug("Processing complete, opening next video: %s", self._paths[self._index]) + self._cap = self._open_source(self._index) + + def seek(self, target: TimecodeLike): + """Seek to `target` on the global timeline. Supports seeking across sources in + either direction.""" + if not isinstance(target, FrameTimecode): + target = FrameTimecode(target, self.frame_rate) + if target < 0: + raise ValueError("Target seek position cannot be negative!") + target_seconds = _exact_seconds(target) + # Find the last source which starts at or before the target. + index = bisect.bisect_right(self._offsets, target_seconds) - 1 + index = max(0, min(index, len(self._paths) - 1)) + if index != self._index: + self._decode_failures_prior += self._cap.decode_failures + self._frames_prior = sum(source.frames for source in self._sources[:index]) + self._index = index + self._cap = self._open_source(index) + local_seconds = target_seconds - self._offsets[index] + self._cap.seek(float(local_seconds)) + + def reset(self): + """Close and re-open the stream (equivalent to seeking back to the beginning).""" + self._index = 0 + self._frames_prior = 0 + self._decode_failures_prior = 0 + self._cap = self._open_source(0) + + # + # VideoStream Properties + # + + @property + def path(self) -> str: + """Path of the first input video.""" + return str(self._paths[0]) + + @property + def name(self) -> str: + """Name of the first input video, without extension.""" + return self._paths[0].stem + + @property + def is_seekable(self) -> bool: + return self._cap.is_seekable + + @property + def frame_rate(self) -> Fraction: + """Average framerate of the first input video. Individual sources may vary; use + `position` for accurate timing.""" + if self._frame_rate_override is not None: + return Fraction(self._frame_rate_override) + return self._sources[0].frame_rate + + @property + def duration(self) -> FrameTimecode: + """Total duration of all input videos combined. May be inaccurate.""" + return FrameTimecode( + timecode=Timecode( + pts=round(self._offsets[-1] / _GLOBAL_TIME_BASE), time_base=_GLOBAL_TIME_BASE + ), + fps=self.frame_rate, + ) + + @property + def frame_size(self) -> tuple[int, int]: + """Video resolution (width x height) in pixels.""" + return self._sources[0].frame_size + + @property + def aspect_ratio(self) -> float: + return self._sources[0].aspect_ratio + + @property + def position(self) -> FrameTimecode: + """Presentation time of the last-read frame on the global timeline (the first frame + of the first video has a presentation time of 0).""" + global_seconds = self._offsets[self._index] + self._child_position_seconds() + return FrameTimecode( + timecode=Timecode( + pts=round(global_seconds / _GLOBAL_TIME_BASE), time_base=_GLOBAL_TIME_BASE + ), + fps=self.frame_rate, + ) + + @property + def position_ms(self) -> float: + """Presentation time of the last-read frame in milliseconds on the global timeline.""" + return float((self._offsets[self._index] + self._child_position_seconds()) * 1000) + + @property + def frame_number(self) -> int: + """Number of frames read so far across all sources.""" + return self._frames_prior + self._cap.frame_number + + @property + def decode_failures(self) -> int: + """Number of frames which failed to decode across all sources.""" + return self._decode_failures_prior + self._cap.decode_failures + + # + # Concatenation-Specific Properties/Methods + # + + @property + def paths(self) -> list[Path]: + """All paths this object was created with.""" + return self._paths + + @property + def child_backend(self) -> str: + """Name of the backend used to decode each input video.""" + return self._cap.BACKEND_NAME + + def map_span(self, start: FrameTimecode, end: FrameTimecode) -> list[SourceSpan]: + """Map a time span on the global timeline to the input video(s) covering it. + A span which straddles one or more file boundaries yields multiple entries.""" + start_seconds = _exact_seconds(start) + end_seconds = _exact_seconds(end) + spans: list[SourceSpan] = [] + for index, source in enumerate(self._sources): + source_start, source_end = self._offsets[index], self._offsets[index + 1] + if end_seconds <= source_start: + break + if start_seconds >= source_end: + continue + local_start = max(Fraction(0), start_seconds - source_start) + local_end = min(source_end - source_start, end_seconds - source_start) + spans.append( + SourceSpan( + source_index=index, + path=source.path, + local_start=FrameTimecode( + timecode=Timecode( + pts=round(local_start / _GLOBAL_TIME_BASE), + time_base=_GLOBAL_TIME_BASE, + ), + fps=source.frame_rate, + ), + local_end=FrameTimecode( + timecode=Timecode( + pts=round(local_end / _GLOBAL_TIME_BASE), + time_base=_GLOBAL_TIME_BASE, + ), + fps=source.frame_rate, + ), + ) + ) + return spans diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 14758952..d3167b26 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. # @@ -16,7 +16,11 @@ image sequences or AviSynth scripts are supported as inputs. """ +import os +import time import typing as ty +import warnings +from fractions import Fraction from logging import getLogger import cv2 @@ -24,56 +28,111 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode -from scenedetect.platform import get_file_name +from scenedetect.common import ( + FrameRate, + 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") +# 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: OSError | None = 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.""" def __init__( - self, path: ty.AnyStr, framerate: ty.Optional[float] = None, print_infos: bool = False + self, + path: StrPath, + frame_rate: FrameRate | None = None, + print_infos: bool = False, + framerate: float | None = None, ): """Open a video or device. Arguments: path: Path to video,. - framerate: If set, overrides the detected framerate. + frame_rate: If set, overrides the detected frame rate. Takes precedence over + `framerate`. print_infos: If True, prints information about the opened video to stdout. + framerate: [DEPRECATED] Use `frame_rate` instead. Retained as a deprecated + alias for backwards compatibility; ignored when `frame_rate` is provided. Raises: OSError: file could not be found, access was denied, or the video is corrupt VideoOpenFailure: video could not be opened (may be corrupted) + ValueError: specified frame rate is invalid """ super().__init__() - # TODO: Investigate how MoviePy handles ffmpeg not being on PATH. - # TODO: Add framerate override. if framerate is not None: - raise NotImplementedError( - "VideoStreamMoviePy does not support the `framerate` argument yet." + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, ) - - self._path = path + if frame_rate is None: + frame_rate = framerate + # TODO: Investigate how MoviePy handles ffmpeg not being on PATH. + if frame_rate is not None and frame_rate <= 0: + raise ValueError(f"Specified frame rate ({float(frame_rate):f}) is invalid!") + # The override - if set - takes precedence over the rate reported by the reader. + # MoviePy assumes CFR, so changing the rate is equivalent to reinterpreting frame + # timestamps at a different cadence; the source's wall-clock duration is unaffected. + self._frame_rate_override: Fraction | None = ( + framerate_to_fraction(frame_rate) if frame_rate is not None else None + ) + + 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 = FFMPEG_VideoReader(path, print_infos=print_infos) + self._reader = _retry_on_oserror( + "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 # 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 # 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 @@ -83,12 +142,15 @@ def __init__( """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: - """Framerate in frames/sec.""" - return self._reader.fps + def frame_rate(self) -> Fraction: + """Framerate in frames/sec as a rational Fraction. Returns the override passed at + construction if one was provided; otherwise the rate reported by MoviePy's reader.""" + if self._frame_rate_override is not None: + return self._frame_rate_override + return framerate_to_fraction(self._reader.fps) @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> str: """Video path.""" return self._path @@ -103,12 +165,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"] @@ -135,7 +197,14 @@ def position(self) -> FrameTimecode: calling `read`. This will always return 0 (e.g. be equal to `base_timecode`) if no frames have been `read` yet.""" frame_number = max(self._frame_number - 1, 0) - return FrameTimecode(frame_number, self.frame_rate) + # Synthesize a Timecode from the frame count and rational framerate. + # MoviePy assumes CFR, so this is equivalent to frame-based timing. + # Use the framerate denominator as the time_base denominator for exact timing. + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = frame_number * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=fps) @property def position_ms(self) -> float: @@ -153,7 +222,7 @@ def frame_number(self) -> int: """ return self._frame_number - def seek(self, target: ty.Union[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). @@ -173,15 +242,15 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ValueError: `target` is not a valid value (i.e. it is negative). """ success = False - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. - raise NotImplementedError() - if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) + duration = self.duration + assert duration is not None try: - self._last_frame = self._reader.get_frame(target.seconds) - if hasattr(self._reader, "last_read") and target >= self.duration: + self._last_frame = _retry_on_oserror( + "seek", lambda: self._reader.get_frame(target.seconds) + ) + 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, @@ -194,7 +263,7 @@ def seek(self, target: ty.Union[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: @@ -208,9 +277,11 @@ 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]: + 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") @@ -223,9 +294,8 @@ def read(self, decode: bool = True) -> ty.Union[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) - 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/opencv.py b/scenedetect/backends/opencv.py index 298f0301..83cd60a8 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. # @@ -18,8 +18,8 @@ """ import math +import os import os.path -import typing as ty import warnings from fractions import Fraction from logging import getLogger @@ -27,8 +27,15 @@ import cv2 import numpy as np -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode -from scenedetect.platform import get_file_name +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameRate, + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, SeekError, @@ -65,17 +72,19 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: ty.AnyStr = None, - framerate: ty.Optional[float] = None, + path: StrPath | None = None, + frame_rate: FrameRate | None = None, max_decode_attempts: int = 5, - path_or_device: ty.Union[bytes, str, int] = None, + path_or_device: StrPath | int | None = None, + framerate: float | None = None, ): """Open a video file, image sequence, or network stream. Arguments: path: Path to the video. Can be a file, image sequence (`'folder/DSC_%04d.jpg'`), or network stream. - framerate: If set, overrides the detected framerate. + frame_rate: If set, overrides the detected frame rate. Takes precedence over + `framerate`. max_decode_attempts: Number of attempts to continue decoding the video after a frame fails to decode. This allows processing videos that have a few corrupted frames or metadata (in which case accuracy @@ -83,43 +92,54 @@ def __init__( decoding will stop and emit an error. path_or_device: [DEPRECATED] Specify `path` for files, image sequences, or network streams/URLs. Use `VideoCaptureAdapter` for devices/pipes. + framerate: [DEPRECATED] Use `frame_rate` instead. Retained as a deprecated + alias for backwards compatibility; ignored when `frame_rate` is provided. Raises: OSError: file could not be found or access was denied VideoOpenFailure: video could not be opened (may be corrupted) - ValueError: specified framerate is invalid + ValueError: specified frame rate is invalid """ super().__init__() + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) + if frame_rate is None: + frame_rate = framerate if path_or_device is not None: 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, ) - 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!") - if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + else: + resolved = os.fspath(path) + if frame_rate is not None and frame_rate < MAX_FPS_DELTA: + raise ValueError(f"Specified frame rate ({float(frame_rate):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: - self._cap: ty.Optional[cv2.VideoCapture] = ( - None # Reference to underlying cv2.VideoCapture object. - ) - self._frame_rate: ty.Optional[float] = None - # VideoCapture state self._has_grabbed = False self._max_decode_attempts = max_decode_attempts self._decode_failures = 0 self._warning_displayed = False - self._open_capture(framerate) + # `_open_capture` populates `_cap` and `_frame_rate`. + self._open_capture(frame_rate) # # Backend-Specific Methods/Properties @@ -133,7 +153,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 # @@ -144,16 +163,15 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: - assert self._frame_rate + def frame_rate(self) -> Fraction: return self._frame_rate @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> str: if self._is_device: - assert isinstance(self._path_or_device, (int)) - return "Device %d" % self._path_or_device - assert isinstance(self._path_or_device, (bytes, str)) + assert isinstance(self._path_or_device, int) + return f"Device {self._path_or_device}" + assert isinstance(self._path_or_device, str) return self._path_or_device @property @@ -175,7 +193,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 +201,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 @@ -196,30 +214,26 @@ def aspect_ratio(self) -> float: @property def timecode(self) -> Timecode: - """Current position within stream as a Timecode. This is not frame accurate.""" + """Current position within stream as a Timecode.""" # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we - # use `CAP_PROP_POS_MSEC` instead, with a time base of 1/1000. Unfortunately this means that - # rounding errors will affect frame accuracy with this backend. - pts = self._cap.get(cv2.CAP_PROP_POS_MSEC) - time_base = Fraction(1, 1000) - return Timecode(pts=round(pts), time_base=time_base) + # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to + # avoid frame-boundary rounding errors at common framerates like 24000/1001. + ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + time_base = Fraction(1, 1000000) + return Timecode(pts=round(ms * 1000), time_base=time_base) @property def position(self) -> FrameTimecode: - # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or - # add a config option before landing this. - if _USE_PTS_IN_DEVELOPMENT: - timecode = self.timecode - # If PTS is 0 but we've read frames, derive from frame number. - # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. - if timecode.pts == 0 and self.frame_number > 0: - time_sec = (self.frame_number - 1) / self.frame_rate - pts = round(time_sec * 1000) - timecode = Timecode(pts=pts, time_base=Fraction(1, 1000)) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) - if self.frame_number < 1: - return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + timecode = self.timecode + # If PTS is non-positive but we've read frames, derive from frame number. This handles + # image sequences and cases where CAP_PROP_POS_MSEC is unreliable. OpenCV 5 reports + # CAP_PROP_POS_MSEC as -1 (rather than 0) for image sequences on Windows, so check <= 0. + if timecode.pts <= 0 and self.frame_number > 0: + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = (self.frame_number - 1) * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) @property def position_ms(self) -> float: @@ -229,42 +243,53 @@ 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: 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!") - - # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. - # Have to seek one behind and call grab() after to that the VideoCapture - # returns a valid timestamp when using CAP_PROP_POS_MSEC. - target_frame_cv2 = (self.base_timecode + target).frame_num - if target_frame_cv2 > 0: - target_frame_cv2 -= 1 - self._cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame_cv2) + target_secs = (self.base_timecode + target).seconds self._has_grabbed = False - # Preemptively grab the frame behind the target position if possible. - if target > 0: + if target_secs > 0: + # Seek one frame before target so the next read() returns the frame at target. + one_frame_ms = 1000.0 / float(self._frame_rate) + seek_ms = max(0.0, target_secs * 1000.0 - one_frame_ms) + self._cap.set(cv2.CAP_PROP_POS_MSEC, seek_ms) self._has_grabbed = self._cap.grab() - # If we seeked past the end of the video, need to seek one frame backwards - # from the current position and grab that frame instead. + if self._has_grabbed: + # VFR correction: set(CAP_PROP_POS_MSEC) converts time using avg_fps internally, + # which can land ~1s too early for VFR video. Read forward until we reach the + # intended position. The threshold (2x one_frame_ms) never triggers for CFR. + actual_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + corrections = 0 + while actual_ms < seek_ms - 2.0 * one_frame_ms and corrections < 100: + if not self._cap.grab(): + break + actual_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + corrections += 1 + # If we seeked past the end, back up one frame. if not self._has_grabbed: seek_pos = round(self._cap.get(cv2.CAP_PROP_POS_FRAMES) - 1.0) self._cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, seek_pos)) self._has_grabbed = self._cap.grab() + else: + self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0) def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._cap.release() - self._open_capture(self._frame_rate) + self._open_capture(float(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() # 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: @@ -289,17 +314,23 @@ 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, frame_rate: FrameRate | 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(): @@ -322,21 +353,20 @@ def _open_capture(self, framerate: ty.Optional[float] = None): # Ensure the framerate is correct to avoid potential divide by zero errors. This can be # addressed in the PyAV backend if required since it supports integer timebases. - assert framerate is None or framerate > MAX_FPS_DELTA, "Framerate must be validated if set!" - if framerate is None: - framerate = cap.get(cv2.CAP_PROP_FPS) - if framerate < MAX_FPS_DELTA: + assert frame_rate is None or frame_rate > MAX_FPS_DELTA, ( + "Frame rate must be validated if set!" + ) + if frame_rate is None: + frame_rate = cap.get(cv2.CAP_PROP_FPS) + if frame_rate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._cap = cap - self._frame_rate = framerate + self._cap: cv2.VideoCapture = cap + self._frame_rate: Fraction = framerate_to_fraction(frame_rate) self._has_grabbed = False cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 -# TODO(https://scenedetect.com/issues/168): Support non-monotonic timing for `position`. VFR timecode -# support is a prerequisite for this. Timecodes are currently calculated by multiplying the -# framerate by number of frames. Actual elapsed time can be obtained via `position_ms` for now. class VideoCaptureAdapter(VideoStream): """Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports VideoCaptures which may not support seeking. @@ -345,8 +375,9 @@ class VideoCaptureAdapter(VideoStream): def __init__( self, cap: cv2.VideoCapture, - framerate: ty.Optional[float] = None, + frame_rate: FrameRate | None = None, max_read_attempts: int = 5, + framerate: float | None = None, ): """Create from an existing OpenCV VideoCapture object. Used for webcams, live streams, pipes, or other inputs which may not support seeking. @@ -354,31 +385,43 @@ def __init__( Arguments: cap: The `cv2.VideoCapture` object to wrap. Must already be opened and ready to have `cap.read()` called on it. - framerate: If set, overrides the detected framerate. + frame_rate: If set, overrides the detected frame rate. Takes precedence over + `framerate`. max_read_attempts: Number of attempts to continue decoding the video after a frame fails to decode. This allows processing videos that have a few corrupted frames or metadata (in which case accuracy of detection algorithms may be lower). Once this limit is passed, decoding will stop and emit an error. + framerate: [DEPRECATED] Use `frame_rate` instead. Retained as a deprecated + alias for backwards compatibility; ignored when `frame_rate` is provided. Raises: - ValueError: capture is not open, framerate or max_read_attempts is invalid + ValueError: capture is not open, frame rate or max_read_attempts is invalid """ super().__init__() - if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) + if frame_rate is None: + frame_rate = framerate + if frame_rate is not None and frame_rate < MAX_FPS_DELTA: + raise ValueError(f"Specified frame rate ({float(frame_rate):f}) is invalid!") if max_read_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") if not cap.isOpened(): raise ValueError("Specified VideoCapture must already be opened!") - if framerate is None: - framerate = cap.get(cv2.CAP_PROP_FPS) - if framerate < MAX_FPS_DELTA: + if frame_rate is None: + frame_rate = cap.get(cv2.CAP_PROP_FPS) + if frame_rate < MAX_FPS_DELTA: raise FrameRateUnavailable() self._cap = cap - self._frame_rate: float = framerate + self._frame_rate: Fraction = framerate_to_fraction(frame_rate) self._num_frames = 0 self._max_read_attempts = max_read_attempts self._decode_failures = 0 @@ -397,7 +440,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 # @@ -408,9 +450,8 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: + def frame_rate(self) -> Fraction: """Framerate in frames/sec.""" - assert self._frame_rate return self._frame_rate @property @@ -429,7 +470,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)), @@ -437,10 +478,8 @@ 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.""" - # TODO(https://scenedetect.com/issue/168): This will be incorrect for VFR. See if there is - # another property we can use to estimate the video length correctly. frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: return self.base_timecode + frame_count @@ -455,7 +494,12 @@ def aspect_ratio(self) -> float: def position(self) -> FrameTimecode: if self.frame_number < 1: return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + # Synthesize a Timecode from frame count and rational framerate. + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = (self.frame_number - 1) * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=fps) @property def position_ms(self) -> float: @@ -467,7 +511,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: TimecodeLike): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") @@ -475,7 +519,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 8692cdb5..4fd60ea6 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -5,26 +5,39 @@ # [ 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. # """:class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" +import os import typing as ty +import warnings from fractions import Fraction from logging import getLogger import av import numpy as np -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode -from scenedetect.platform import get_file_name +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameRate, + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +MAX_CONSECUTIVE_DECODE_FAILURES = 8 +"""Number of consecutive frame decode failures after which `VideoStreamAv.read()` gives up. +Isolated corrupt frames are skipped; this bound ensures a truncated file still terminates.""" + class VideoStreamAv(VideoStream): """PyAV `av.InputContainer` backend.""" @@ -35,11 +48,12 @@ 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: StrPath | ty.BinaryIO, + frame_rate: FrameRate | None = None, + name: str | None = None, + threading_mode: str | None = None, suppress_output: bool = False, + framerate: FrameRate | None = None, ): """Open a video by path. @@ -51,7 +65,8 @@ def __init__( Arguments: path_or_io: Path to the video, or a file-like object. - framerate: If set, overrides the detected framerate. + frame_rate: If set, overrides the detected frame rate. Takes precedence over + `framerate`. name: Overrides the `name` property derived from the video path. Should be set if `path_or_io` is a file-like object. threading_mode: The PyAV video stream `thread_type`. See av.codec.context.ThreadType @@ -62,49 +77,63 @@ def __init__( `av.logging.restore_default_callback()` before any other library calls. If True the application may deadlock if threading_mode is set. See the PyAV documentation for details: https://pyav.org/docs/stable/overview/caveats.html#sub-interpeters + framerate: [DEPRECATED] Use `frame_rate` instead. Retained as a deprecated + alias for backwards compatibility; ignored when `frame_rate` is provided. Raises: OSError: file could not be found or access was denied VideoOpenFailure: video could not be opened (may be corrupted) - ValueError: specified framerate is invalid + ValueError: specified frame rate is invalid """ - self._container = None - - # TODO(https://scenedetect.com/issues/258): See what `self._container.discard_corrupt = True` - # does with corrupt videos. + # NOTE(https://scenedetect.com/issues/258): `read()` skips over corrupt packets and + # continues decoding. `self._container.discard_corrupt = True` may be a future + # refinement for frames FFmpeg flags as corrupt but still decodes. super().__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) + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) + if frame_rate is None: + frame_rate = framerate + # Ensure specified frame rate is valid if set. + if frame_rate is not None and frame_rate < MAX_FPS_DELTA: + raise ValueError(f"Specified frame rate ({float(frame_rate):f}) is invalid!") self._name = "" if name is None else name self._path = "" - self._frame: ty.Optional[av.VideoFrame] = None + self._frame: av.VideoFrame | None = None + self._decoder: ty.Generator | None = None self._reopened = True + self._decode_failures = 0 + self._warning_displayed = False 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( - "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: 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, bytes)): - self._path = path_or_io - self._io = open(path_or_io, "rb") + 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(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 - 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 @@ -114,30 +143,39 @@ def __init__( except Exception as ex: raise VideoOpenFailure(str(ex)) from ex - if framerate is None: - # Calculate framerate from video container. `guessed_rate` below appears in PyAV 9. - frame_rate = ( + if frame_rate is None: + # Calculate frame rate from video container. `guessed_rate` below appears in PyAV 9. + detected_rate = ( self._video_stream.guessed_rate if hasattr(self._video_stream, "guessed_rate") else self._codec_context.framerate ) - if frame_rate is None or frame_rate == 0: + if detected_rate is None or detected_rate == 0: raise FrameRateUnavailable() - if frame_rate < MAX_FPS_DELTA: + if detected_rate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._frame_rate: Fraction = frame_rate + self._frame_rate: Fraction = framerate_to_fraction(detected_rate) else: - assert framerate >= MAX_FPS_DELTA - self._frame_rate: Fraction = ( - framerate if isinstance(framerate, Fraction) else Fraction.from_float(framerate) - ) + assert frame_rate >= MAX_FPS_DELTA + self._frame_rate: Fraction = framerate_to_fraction(frame_rate) # Calculate duration after we have set the framerate. self._duration_frames = self._get_duration() def __del__(self): - if self._container is not None: - self._container.close() + # Finalizers must never raise - an exception here becomes an unraisable error. During + # interpreter shutdown the underlying handles are reclaimed by the OS anyway. + try: + # Close the decode generator first to break its cycle with the container. + decoder = getattr(self, "_decoder", None) + if decoder is not None: + decoder.close() + # `_container` is unset if `__init__` raised before `av.open()` succeeded. + container = getattr(self, "_container", None) + if container is not None: + container.close() + except Exception: + pass # # VideoStream Methods/Properties @@ -147,12 +185,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> str: """Video path.""" return self._path @property - def name(self) -> ty.Union[bytes, str]: + def name(self) -> str: """Name of the video, without extension.""" return self._name @@ -162,7 +200,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) @@ -172,8 +210,8 @@ def duration(self) -> FrameTimecode: return self.base_timecode + self._duration_frames @property - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction.""" return self._frame_rate @property @@ -182,40 +220,36 @@ def position(self) -> FrameTimecode: This can be interpreted as presentation time stamp, thus frame 1 corresponds to the presentation time 0. Returns 0 even if `frame_number` is 1.""" - if self._frame is None: + if self._frame is None or self._frame.pts is None or self._frame.time_base is None: return self.base_timecode - if _USE_PTS_IN_DEVELOPMENT: - timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) - return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate) + timecode = Timecode(pts=self._normalized_pts(), time_base=self._frame.time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) @property def position_ms(self) -> float: """Current position within stream as a float of the presentation time in milliseconds. The first frame has a PTS of 0.""" - if self._frame is None: + if self._frame is None or self._frame.pts is None or self._frame.time_base is None: return 0.0 - return self._frame.time * 1000.0 + return float(self._normalized_pts() * self._frame.time_base) * 1000.0 @property def frame_number(self) -> int: - """Current position within stream as the frame number. - - Will return 0 until the first frame is `read`.""" + """Current position within stream as the frame number (CFR-equivalent). - if self._frame: - if _USE_PTS_IN_DEVELOPMENT: - # frame_number is 1-indexed, so add 1 to the 0-based frame position. - return round(self._frame.time * self.frame_rate) + 1 - return self.position.frame_num + 1 - return 0 + Will return 0 until the first frame is `read`. For VFR video this is an approximation + derived from PTS * framerate; use `position` for accurate PTS-based timing.""" + if self._frame is None or self._frame.pts is None or self._frame.time_base is None: + return 0 + seconds = float(self._normalized_pts() * self._frame.time_base) + return round(seconds * float(self.frame_rate)) + 1 @property 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 @@ -236,7 +270,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: 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). @@ -254,11 +288,12 @@ def seek(self, target: ty.Union[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 - # TODO(https://scenedetect.com/issues/168): This breaks with PTS mode enabled. target = self.base_timecode + target if target >= 1: target = target - 1 @@ -266,6 +301,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: (self.base_timecode + target).seconds / self._video_stream.time_base ) self._frame = None + self._decoder = None self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -277,23 +313,54 @@ def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._container.close() self._frame = None + self._decoder = None try: self._container = av.open(self._path if self._path else self._io) except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: - try: - last_frame = self._frame - self._frame = next(self._container.decode(video=0)) - except av.error.EOFError: - self._frame = last_frame - if self._handle_eof(): - return self.read(decode) - return False - except StopIteration: - return False - return self._frame.to_ndarray(format="bgr24") if decode else True + def read(self, decode: bool = True) -> np.ndarray | bool: + consecutive_failures = 0 + while True: + # Reuse a persistent decoder generator so the codec's internal frame buffer (used for + # B-frame reordering) is never flushed prematurely. Creating a new generator each call + # caused the last buffered frame to be lost at EOF. + if self._decoder is None: + self._decoder = self._container.decode(video=0) + try: + last_frame = self._frame + assert self._decoder is not None + self._frame = next(self._decoder) + # NOTE: EOFError subclasses FFmpegError, so this clause must come first. + except av.error.EOFError: # type: ignore[attr-defined] + self._frame = last_frame + if self._handle_eof(): + return self.read(decode) + return False + except StopIteration: + return False + except av.error.FFmpegError as ex: # type: ignore[attr-defined] + # `next()` raised before assignment, so `self._frame` is still the last good + # frame and position/frame_number are unaffected by the skipped packet. + self._decode_failures += 1 + consecutive_failures += 1 + # The decoder generator is closed once an exception propagates through it; + # recreating it (next loop iteration) resumes demuxing after the bad packet. + self._decoder = None + if consecutive_failures >= MAX_CONSECUTIVE_DECODE_FAILURES: + logger.error( + "Failed to decode %d consecutive frames, stopping: %s", + consecutive_failures, + ex, + ) + return False + logger.debug("Frame failed to decode: %s", ex) + if not self._warning_displayed and self._decode_failures > 1: + self._warning_displayed = True + logger.warning("Failed to decode some frames, results may be inaccurate.") + continue + assert self._frame is not None + return self._frame.to_ndarray(format="bgr24") if decode else True # # Private Methods/Properties @@ -309,6 +376,16 @@ def _codec_context(self): """PyAV `av.codec.context.CodecContext` being used.""" return self._video_stream.codec_context + def _normalized_pts(self) -> int: + """PTS of the current frame relative to the start of the stream. Some files have a + nonzero stream start_time (e.g. from edit lists); other backends report the first + frame's presentation time as 0, so we must do the same.""" + assert self._frame is not None and self._frame.pts is not None + start_time = self._video_stream.start_time or 0 + if start_time and self._video_stream.time_base != self._frame.time_base: + start_time = int(start_time * self._video_stream.time_base / self._frame.time_base) + return self._frame.pts - start_time + def _get_duration(self) -> int: """Get video duration as number of frames based on the video and set framerate.""" # See https://pyav.org/docs/develop/api/time.html for details on how ffmpeg/PyAV @@ -350,7 +427,7 @@ def _handle_eof(self): # Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode. if not self.is_seekable or self._video_stream.thread_type not in ("AUTO", "FRAME"): return False - last_frame = self.frame_number + last_pos_secs = self.position.seconds orig_pos = self._io.tell() try: self._io.seek(0) @@ -360,5 +437,6 @@ def _handle_eof(self): raise self._container.close() self._container = container - self.seek(last_frame) + self._decoder = None + self.seek(last_pos_secs) return True diff --git a/scenedetect/common.py b/scenedetect/common.py index e4ab7e48..81086f60 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. # @@ -23,9 +23,9 @@ .. code:: python - frames = FrameTimecode(timecode = 29, fps = 29.97) - seconds_float = FrameTimecode(timecode = 10.0, fps = 10.0) - timecode_str = FrameTimecode(timecode = "00:00:10.000", fps = 10.0) + frames = FrameTimecode(29, 29.97) + seconds_float = FrameTimecode(10.0, 10.0) + timecode_str = FrameTimecode("00:00:10.000", 10.0) Arithmetic/comparison operations with :class:`FrameTimecode` objects is also possible, and the @@ -33,7 +33,7 @@ .. code:: python - x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0) + x = FrameTimecode("00:01:00.000", 10.0) # Can add int (frames), float (seconds), or str (timecode). print(x + 10) print(x + 10.0) @@ -70,35 +70,80 @@ import cv2 -# TODO(https://scenedetect.com/issue/168): Ensure both CFR and VFR videos work as intended with this -# flag enabled. When this feature is stable, we can then work on a roll-out plan. -_USE_PTS_IN_DEVELOPMENT = False - ## ## Type Aliases ## -SceneList = ty.List[ty.Tuple["FrameTimecode", "FrameTimecode"]] -"""Type hint for a list of scenes in the form (start time, end time).""" - -CutList = ty.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"] -"""Named type for pairs of timecodes, which typically represents the start/end of a scene.""" +CutList = list["FrameTimecode"] +"""Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" + +FrameRate = float | Fraction +"""Type hint for a video frame rate. ``Fraction`` is the canonical exact form and should be +preferred (e.g. ``Fraction(30000, 1001)``), while ``float`` is accepted for convenience. Floats +will be converted to rationals at runtime via :func:`framerate_to_fraction`.""" + +SceneList = list[tuple["FrameTimecode", "FrameTimecode"]] +"""Type hint for a list of scenes in the form (start time, end time).""" + +# `Timecode` and `FrameTimecode` are defined later in this module; using `typing.Union` with +# string forward refs is the only form that both works at the top of the file (the PEP 604 `|` +# syntax can't accept string forward refs) and supports `TimecodeLike | None` at use sites. +TimecodeLike: ty.TypeAlias = ty.Union[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`.""" + +TimecodePair = tuple["FrameTimecode", "FrameTimecode"] +"""Type hint for timecode pairs, typically representing the start/end of a scene.""" + +## +## Constants +## MAX_FPS_DELTA: float = 1.0 / 1000000000.0 """Maximum amount two framerates can differ by for equality testing. Currently 1 frame/nanosec.""" +# `datetime.timedelta` does not expose seconds per minute/hour as constants, so we define our own. _SECONDS_PER_MINUTE = 60.0 _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE _MINUTES_PER_HOUR = 60.0 +# Tolerance for snapping a float value's framerate to an NTSC-derived rational (N * 1000/1001). +# e.g. 23.976 should be detected as 24000/1001, 29.97 should be detected as 30000/1001, etc. +_NTSC_DETECTION_TOLERANCE: float = 1e-3 + + +## +## Helpers +## + + +def framerate_to_fraction(fps: "FrameRate") -> Fraction: + """Convert a framerate value to an exact rational Fraction. + + Detects NTSC-derived framerates of the form ``N * 1000/1001`` (e.g. 23.976 -> 24000/1001, + 29.97 -> 30000/1001, 47.952 -> 48000/1001) for any positive integer ``N`` and returns + their exact rational representation. Whole-number framerates are returned as + ``Fraction(N, 1)``. Other values fall back to ``limit_denominator(10000)`` for a clean + rational approximation. ``Fraction`` inputs are returned directly without conversion. + """ + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + if isinstance(fps, Fraction): + return fps + if fps == int(fps): + return Fraction(int(fps), 1) + # Invert fps = N * 1000/1001 to recover N, then verify within tolerance. + base = round(fps * 1001 / 1000) + if base > 0 and abs(base * 1000 / 1001 - fps) < _NTSC_DETECTION_TOLERANCE: + return Fraction(base * 1000, 1001) + return Fraction(fps).limit_denominator(10000) + class Interpolation(Enum): """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" @@ -115,24 +160,6 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -# TODO(@Breakthrough): How should we deal with frame numbers when we have a `Timecode`? -# -# Each backend has slight nuances we have to take into account: -# - PyAV: Does not include a position in frames, we can probably estimate it. Need to also compare -# with how OpenCV handles this. It also seems to fail to decode the last frame. This library -# provides the most accurate timing information however. -# - OpenCV: Lacks any kind of timebase, only provides position in milliseconds and as frames. -# This is probably sufficient, since we could just use 1ms as a timebase. -# - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is -# probably not feasible, so we should make sure the docs warn users about this. -# -# In the meantime, having backends provide accurate timing information is controlled by a hard-coded -# constant `_USE_PTS_IN_DEVELOPMENT` in each backend implementation that supports it. It still does -# not work correctly however, as we have to modify detectors themselves to work with FrameTimecode -# objects instead of integer frame numbers like they do now. -# -# We might be able to avoid changing the detector interface if we just have them work directly with -# PTS and convert them back to FrameTimecodes with the same time base. @dataclass(frozen=True) class Timecode: """Timing information associated with a given frame.""" @@ -169,12 +196,24 @@ class FrameTimecode: 1. Timecode as `str` in the form "HH:MM:SS[.nnn]" (`"01:23:45"` or `"01:23:45.678"`) 2. Number of seconds as `float`, or `str` in form "SSSS.nnnn" (`"45.678"`) 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) + + Rate-related properties: + * :attr:`framerate` is a ``float`` (legacy / deprecated alias). + * :attr:`frame_rate` is a ``Fraction`` and is the canonical form. Both represent + the same rate. + * :attr:`time_base` equals ``1 / frame_rate`` for CFR sources. For VFR + (:class:`Timecode`-backed) instances, ``time_base`` is authoritative and + ``frame_rate`` is an approximation. + + Comparisons between two :class:`Timecode`-backed instances with the same rate are performed + exactly using ``pts * time_base`` as rational numbers. All other comparisons between two + rated instances use frame numbers, which for VFR sources are approximations. """ def __init__( self, - timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None, - fps: ty.Union[float, "FrameTimecode", Fraction] = None, + timecode: "TimecodeLike", + fps: "float | FrameTimecode | Fraction | None" = None, ): """ Arguments: @@ -186,37 +225,21 @@ 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 + 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): @@ -233,25 +256,18 @@ 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) -> ty.Optional[int]: - """The frame number. This value will be an estimate if the video is VFR. Prefer using the - `pts` property.""" + 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): - # We need to audit anything currently using this property to guarantee temporal - # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). - warnings.warn( - message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", - stacklevel=2, - category=UserWarning, - ) # Calculate approximate frame number from seconds and framerate. if self._rate is not None: return round(self._time.seconds * float(self._rate)) @@ -262,10 +278,30 @@ def frame_num(self) -> ty.Optional[int]: return self._time.value @property - def framerate(self) -> ty.Optional[float]: - """The framerate to use for distance between frames and to calculate frame numbers. - For a VFR video, this may just be the average framerate. Returns None if framerate - is unknown (e.g. when working with pure Timecode representations).""" + def frame_rate(self) -> Fraction | None: + """The frame rate as an exact rational :class:`fractions.Fraction`. + + For CFR sources this equals ``1 / time_base``. For VFR sources the rate may be an + approximation (e.g. the average framerate); prefer :attr:`time_base` for exact PTS + arithmetic. Returns ``None`` for timecodes constructed without an associated rate + (i.e. pure :class:`Timecode` representations). + """ + return self._rate + + @property + def framerate(self) -> float | None: + """[DEPRECATED] Use :attr:`frame_rate` instead. + + Returns the rate as a ``float`` for legacy compatibility. The new :attr:`frame_rate` + property returns an exact :class:`fractions.Fraction` and matches the naming used by + :attr:`scenedetect.video_stream.VideoStream.frame_rate`. + """ + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if self._rate is None: return None return float(self._rate) @@ -275,6 +311,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 @@ -298,34 +336,51 @@ 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. + Use the `frame_rate` property instead. :meta private: """ warnings.warn( - "get_framerate() is deprecated, use the `framerate` property instead.", + "get_framerate() is deprecated, use the `frame_rate` property instead.", DeprecationWarning, stacklevel=2, ) - return self.framerate + if self.frame_rate is None: + return None + return float(self.frame_rate) - # TODO(https://scenedetect.com/issue/168): Figure out how to deal with VFR here. - def equal_framerate(self, fps) -> bool: - """Equal Framerate: Determines if the passed framerate is equal to that of this object. + def equal_frame_rate(self, other: "float | Fraction | FrameTimecode") -> bool: + """Determine whether the passed frame rate equals this object's frame rate. Arguments: - fps: Framerate to compare against within the precision constant defined in this module - (see :data:`MAX_FPS_DELTA`). + other: Frame rate to compare against within the precision constant defined in this + module (see :data:`MAX_FPS_DELTA`). May be a ``float``, ``Fraction``, or another + :class:`FrameTimecode`. Returns: - bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. + bool: True if ``other`` matches this :class:`FrameTimecode`'s frame rate within + tolerance, False otherwise. """ - # TODO(https://scenedetect.com/issue/168): Support this comparison in the case FPS is not - # set but a timecode is. - return math.fabs(self.framerate - fps) < MAX_FPS_DELTA + if self.frame_rate is None: + return False + if isinstance(other, FrameTimecode): + if other.frame_rate is None: + return False + other = other.frame_rate + return math.fabs(float(self.frame_rate) - float(other)) < MAX_FPS_DELTA + + def equal_framerate(self, fps) -> bool: + """[DEPRECATED] Use :meth:`equal_frame_rate` instead.""" + warnings.warn( + "`equal_framerate()` is deprecated and scheduled for removal in v0.9; " + "use `equal_frame_rate()` instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.equal_frame_rate(fps) @property def seconds(self) -> float: @@ -334,6 +389,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: @@ -366,7 +423,7 @@ def get_timecode( ) -> str: """Get a formatted timecode string of the form HH:MM:SS[.nnn]. - Args: + Arguments: precision: The number of decimal places to include in the output ``[.nnn]``. use_rounding: Rounds the output to the desired precision. If False, the value will be truncated to the specified precision. @@ -377,8 +434,11 @@ def get_timecode( str: The current time in the form ``"HH:MM:SS[.nnn]"``. """ # Compute hours and minutes based off of seconds, and update seconds. - if nearest_frame and self.framerate: - secs = self.frame_num / self.framerate + # For PTS-backed timecodes, the PTS already represents an exact frame boundary, so we use + # `seconds` directly. For non-PTS timecodes, `nearest_frame` snaps to the nearest frame + # boundary using frame_num, which avoids floating point drift in CFR video display. + if nearest_frame and self.frame_rate and not isinstance(self._time, Timecode): + secs = self.frame_num / float(self.frame_rate) else: secs = self.seconds hrs = int(secs / _SECONDS_PER_HOUR) @@ -396,41 +456,35 @@ 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}" + + @staticmethod + def _ensure_fractional(fps: "FrameRate | FrameTimecode") -> Fraction: + """Validate and convert an `fps` argument into a positive `Fraction`. NTSC-like frame rates + are handled via :func:`framerate_to_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, Fraction)): + return framerate_to_fraction(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: ty.Union[int, float]) -> int: - """Parse a timecode number, storing it as the exact number of frames. - Can be passed as frame number (int), seconds (float) - - Raises: - TypeError, ValueError - """ - # Process the timecode value, storing it as an exact number of frames. - # Exact number of frames N - if isinstance(timecode, int): - if timecode < 0: - raise ValueError("Timecode frame number must be positive and greater than zero.") - return timecode - # Number of seconds S - elif isinstance(timecode, float): - if timecode < 0.0: - raise ValueError("Timecode value must be positive and greater than zero.") - return self._seconds_to_frames(timecode) - else: - raise TypeError("Timecode format/type unrecognized.") - def _timecode_to_seconds(self, input: str) -> float: """Parses a string based on the three possible forms (in timecode format, as an integer number of frames, or floating-point seconds, ending with 's'). Exact frame numbers (int) @@ -449,7 +503,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(":") @@ -478,7 +532,7 @@ def _timecode_to_seconds(self, input: str) -> float: raise ValueError("Timecode seconds value must be positive.") return as_float - def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> int: + def _get_other_as_frames(self, other: "TimecodeLike") -> int: """Get the frame number from `other` for arithmetic operations.""" if isinstance(other, int): return other @@ -486,11 +540,14 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] return self._seconds_to_frames(other) if isinstance(other, str): return self._seconds_to_frames(self._timecode_to_seconds(other)) + if isinstance(other, Timecode): + return self._seconds_to_frames(other.seconds) if isinstance(other, FrameTimecode): - # 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): + # If comparing two FrameTimecodes, they must have the same framerate for frame-based + # operations. + if self._rate and other._rate and not self.equal_frame_rate(other._rate): raise ValueError( - "FrameTimecode instances require equal framerate for frame-based arithmetic." + "FrameTimecode instances require equal frame rate for frame-based arithmetic." ) if isinstance(other._time, _FrameNumber): return other._time.value @@ -498,10 +555,13 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] return self._seconds_to_frames(other.seconds) raise TypeError("Cannot obtain frame number for this timecode.") - def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + def __eq__(self, other: "TimecodeLike") -> bool: if other is None: return False - if _compare_as_fixed(self, other): + exact = _compare_as_exact(other, self) + if exact is not None: + return exact[0] == exact[1] + 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): @@ -510,10 +570,13 @@ def __eq__(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 __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + def __ne__(self, other: "TimecodeLike") -> bool: if other is None: return True - if _compare_as_fixed(self, other): + exact = _compare_as_exact(other, self) + if exact is not None: + return exact[0] != exact[1] + 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): @@ -522,8 +585,11 @@ def __ne__(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 __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + def __lt__(self, other: "TimecodeLike") -> bool: + exact = _compare_as_exact(other, self) + if exact is not None: + return exact[0] < exact[1] + 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): @@ -532,8 +598,11 @@ def __lt__(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 __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + def __le__(self, other: "TimecodeLike") -> bool: + exact = _compare_as_exact(other, self) + if exact is not None: + return exact[0] <= exact[1] + 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): @@ -542,8 +611,11 @@ def __le__(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 __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + def __gt__(self, other: "TimecodeLike") -> bool: + exact = _compare_as_exact(other, self) + if exact is not None: + return exact[0] > exact[1] + 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): @@ -552,8 +624,11 @@ def __gt__(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 __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + def __ge__(self, other: "TimecodeLike") -> bool: + exact = _compare_as_exact(other, self) + if exact is not None: + return exact[0] >= exact[1] + 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): @@ -562,39 +637,52 @@ 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: "TimecodeLike") -> "FrameTimecode": + # Narrow `other`'s internal time once so pyright can track it through the dispatch below. + # A bare `Timecode` is treated as its own internal time. + if isinstance(other, FrameTimecode): + other_inner = other._time + elif isinstance(other, Timecode): + other_inner = other + else: + other_inner = None - if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base != other._time.time_base: - raise ValueError("timecodes have different time bases") - self._time = Timecode( - pts=max(0, self._time.pts + other._time.pts), - time_base=self._time.time_base, - ) + if 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_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_inner.time_base) + self_pts = round(Fraction(self._time.pts) * self._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): @@ -604,44 +692,59 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._time = _FrameNumber(max(0, self._time.value + self._get_other_as_frames(other))) return self - def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def __add__(self, other: "TimecodeLike") -> "FrameTimecode": to_return = FrameTimecode(timecode=self) 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: "TimecodeLike") -> "FrameTimecode": + # Narrow `other`'s internal time once so pyright can track it through the dispatch below. + # A bare `Timecode` is treated as its own internal time. + if isinstance(other, FrameTimecode): + other_inner = other._time + elif isinstance(other, Timecode): + other_inner = other + else: + other_inner = None - if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base != other._time.time_base: - raise ValueError("timecodes have different time bases") - self._time = Timecode( - pts=max(0, self._time.pts - other._time.pts), - time_base=self._time.time_base, - ) + if 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_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_inner.time_base) + self_pts = round(Fraction(self._time.pts) * self._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): + # Compute `self - other` in `other`'s time base. + self_pts_in_other_base = round(self.seconds / other_inner.time_base) self._time = Timecode( - pts=max(0, timecode.pts - round(seconds / timecode.time_base)), - time_base=timecode.time_base, + pts=max(0, self_pts_in_other_base - other_inner.pts), + 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): @@ -651,7 +754,7 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._time = _FrameNumber(max(0, self._time.value - self._get_other_as_frames(other))) return self - def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def __sub__(self, other: "TimecodeLike") -> "FrameTimecode": to_return = FrameTimecode(timecode=self) to_return -= other return to_return @@ -680,10 +783,14 @@ def __repr__(self) -> str: def __hash__(self) -> int: # Use frame_num for consistent hashing regardless of internal representation. # This ensures that FrameTimecodes representing the same frame have the same hash, - # enabling proper dictionary lookups in StatsManager. + # enabling proper dictionary lookups in StatsManager (including int-key interop). + # Exact (PTS-based) equality requires equal rates (`_compare_as_exact`), and equal exact + # times with equal rates always derive the same frame_num, so a == b still implies + # hash(a) == hash(b). Distinct exact times which round to the same frame number compare + # unequal and coexist as a hash collision. return self.frame_num - def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: + def _get_other_as_seconds(self, other: "TimecodeLike") -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): # Convert frame number to seconds using framerate. @@ -696,10 +803,45 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" return other if isinstance(other, str): return self._timecode_to_seconds(other) + if isinstance(other, Timecode): + return other.seconds if isinstance(other, FrameTimecode): return other.seconds raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") -def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: - return a._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. + + This is the fallback when `_compare_as_exact` does not apply (i.e. at least one operand + lacks an exact presentation time, or the rates differ).""" + return base._rate is not None and isinstance(other, FrameTimecode) and other._rate is not None + + +def _compare_as_exact(other: ty.Any, base: FrameTimecode) -> "tuple[Fraction, Fraction] | None": + """If both operands carry exact presentation times (are :class:`Timecode`-backed) and share + the same nominal rate, return both times as exact rational seconds (``pts * time_base``) for + comparison, otherwise return ``None``. + + For Timecode-backed instances (e.g. VFR video positions), `frame_num` is an approximation + derived from the average framerate, so distinct presentation times can round to the same + frame number; the rational times are exact. The same-rate requirement keeps cross-rate + comparisons on the frame-number path, which both preserves existing cross-rate semantics and + guarantees ``__eq__``/``__hash__`` consistency: ``__hash__`` is derived from ``frame_num`` + (rate-dependent), and for equal rates, equal exact times always produce equal frame numbers. + + Returns the extracted pair instead of acting as a type guard since a TypeGuard can only + narrow `other`, not `other._time` or `base._time`. + """ + if ( + isinstance(base._time, Timecode) + and isinstance(other, FrameTimecode) + and isinstance(other._time, Timecode) + and base._rate == other._rate + ): + return ( + base._time.pts * base._time.time_base, + other._time.pts * other._time.time_base, + ) + return None diff --git a/scenedetect/detector.py b/scenedetect/detector.py index e7d9e731..e06e1440 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. # @@ -24,13 +24,13 @@ event (in, out, cut, etc...). """ -import typing as ty +import math from abc import ABC, abstractmethod from enum import Enum import numpy -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, Timecode, TimecodeLike from scenedetect.stats_manager import StatsManager @@ -41,17 +41,17 @@ 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: + Arguments: timecode: Timecode corresponding to the frame being processed. frame_img: Video frame as a 24-bit BGR image. @@ -61,10 +61,10 @@ def process_frame( # Optional Methods - def post_process(self, timecode: int) -> ty.List[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Called after there are no more frames to process. - Args: + Arguments: timecode: The last position in the video which was read. Returns: @@ -81,7 +81,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 @@ -90,10 +90,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: @@ -114,25 +114,51 @@ 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: TimecodeLike): """ Arguments: mode: The mode to use when enforcing `length`. - length: Number of frames to use when filtering cuts. + length: Minimum scene length. Accepts any :data:`TimecodeLike` value (e.g. + ``int`` frames, ``float`` seconds, ``str`` such as ``"0.6s"`` / + ``"00:00:00.600"``, or a :class:`FrameTimecode` / :class:`Timecode`). """ self._mode = mode - self._filter_length = length # Number of frames to use for activating the filter. - self._last_above = None # Last frame above threshold. + # 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 / Timecode / FrameTimecode) populate + # `_filter_secs`; integer inputs (int/digit str) populate `_filter_length`. + self._filter_length: int = 0 + self._filter_secs: float | None = 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 + elif isinstance(length, (Timecode, FrameTimecode)): + self._filter_secs = length.seconds + else: + self._filter_length = int(length) + 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: - 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: + 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: self._last_above = timecode @@ -142,10 +168,17 @@ 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]: - framerate = timecode.framerate - assert framerate >= 0 - min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) + def _filter_suppress( + self, timecode: FrameTimecode, above_threshold: bool + ) -> list[FrameTimecode]: + frame_rate = timecode.frame_rate + assert frame_rate is not None and frame_rate >= 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 / float(frame_rate) + min_length_met: bool = (timecode - self._last_above) >= self._filter_secs if not (above_threshold and min_length_met): return [] # Both length and threshold requirements were satisfied. Emit the cut, and wait until both @@ -153,17 +186,25 @@ 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]: - framerate = timecode.framerate - assert framerate >= 0 - min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTimecode]: + frame_rate = timecode.frame_rate + assert frame_rate is not None and frame_rate >= 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 / float(frame_rate) + min_length_met: bool = (timecode - self._last_above) >= self._filter_secs # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. - num_merged_frames = self._last_above - self._merge_start - if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + assert self._merge_start is not None + if ( + min_length_met + and not above_threshold + and (self._last_above - self._merge_start) >= self._filter_secs + ): self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 70eb5eb7..16238025 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. # @@ -35,11 +35,11 @@ processing videos, however they can also be used to process frames directly. """ -from scenedetect.detectors.content_detector import ContentDetector # noqa: I001 -from scenedetect.detectors.threshold_detector import ThresholdDetector -from scenedetect.detectors.adaptive_detector import AdaptiveDetector -from scenedetect.detectors.hash_detector import HashDetector -from scenedetect.detectors.histogram_detector import HistogramDetector +from scenedetect.detectors.content_detector import ContentDetector as ContentDetector # noqa: I001 +from scenedetect.detectors.threshold_detector import ThresholdDetector as ThresholdDetector +from scenedetect.detectors.adaptive_detector import AdaptiveDetector as AdaptiveDetector +from scenedetect.detectors.hash_detector import HashDetector as HashDetector +from scenedetect.detectors.histogram_detector import HistogramDetector as HistogramDetector # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 7a0a23af..2e98bbf4 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. # @@ -16,12 +16,11 @@ 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 -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike from scenedetect.detectors import ContentDetector logger = getLogger("pyscenedetect") @@ -38,19 +37,20 @@ class AdaptiveDetector(ContentDetector): def __init__( self, adaptive_threshold: float = 3.0, - min_scene_len: int = 15, + min_scene_len: TimecodeLike = 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: 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 @@ -85,23 +85,25 @@ 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]: - return super().get_metrics() + [self._adaptive_ratio_key] + 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) + # 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 6cf757fa..b666f8c9 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. # @@ -22,7 +22,7 @@ import cv2 import numpy -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike from scenedetect.detector import FlashFilter, SceneDetector @@ -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 @@ -98,23 +98,24 @@ 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: int = 15, + min_scene_len: TimecodeLike = 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, ): """ 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. @@ -126,17 +127,16 @@ 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_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) @@ -168,7 +168,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) ), ) @@ -189,10 +191,10 @@ 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: + Arguments: frame_num (int): Frame number of frame that is being passed. Can start from any value but must remain sequential. frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 484f49d5..395766c9 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. # @@ -16,12 +16,10 @@ This detector is available from the command-line interface by using the `detect-hash` command. """ -import typing as ty - import cv2 import numpy -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike from scenedetect.detector import SceneDetector @@ -41,25 +39,26 @@ 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__( self, - threshold: float = 0.395, - size: int = 16, + threshold: float = 0.35, + size: int = 8, lowpass: int = 2, - min_scene_len: int = 15, + min_scene_len: TimecodeLike = 15, ): - super(HashDetector, self).__init__() + super().__init__() self._threshold = threshold self._min_scene_len = min_scene_len 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}]" @@ -68,7 +67,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.""" @@ -137,14 +136,14 @@ 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 dct_low_freq = dct_complete[:hash_size, :hash_size] # Calculate the median of the low frequency informations - med = numpy.median(dct_low_freq) + med = numpy.median(numpy.asarray(dct_low_freq, dtype=numpy.float32)) # Transform the low frequency information into a binary image based on > or < median hash_img = dct_low_freq > med diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 812c5852..0018606e 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. # @@ -20,7 +20,7 @@ import cv2 import numpy -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike from scenedetect.detector import SceneDetector @@ -28,18 +28,23 @@ 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, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): + def __init__( + self, + threshold: float = 0.20, + bins: int = 128, + min_scene_len: TimecodeLike = 15, + ): """ Arguments: threshold: maximum relative difference between 0.0 and 1.0 that the histograms can differ. Histograms are calculated on the Y channel after converting the frame to - YUV, and normalized based on the number of bins. Higher dicfferences imply greater + YUV, and normalized based on the number of bins. Higher differences 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 any :data:`TimecodeLike` value. """ super().__init__() # Internally, threshold represents the correlation between two histograms and has values @@ -51,20 +56,22 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int 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[FrameTimecode]: """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: - frame_num: Frame number of frame that is being passed. + timecode: Timecode of the frame that is being passed. frame_img: Decoded frame image (numpy.ndarray) to perform scene detection on. Returns: - List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. + List of timecodes where scene cuts have been detected. There may be 0 + or more timecodes in the list, and not necessarily the same as `timecode`. """ cut_list = [] @@ -92,11 +99,12 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty # 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 ): @@ -123,30 +131,26 @@ def calculate_histogram( the specified number of bins, and optionally normalizes this histogram to have a sum of one across all bins. - Args: - ----- - frame_img : np.ndarray - The input image in BGR color space, assumed to have shape (height, width, 3) - where the last dimension represents the BGR channels. - bins : int, optional (default=256) - The number of bins to use for the histogram. - normalize : bool, optional (default=True) - A boolean flag that determines whether the histogram should be normalized - such that the sum of all histogram bins equals 1. + Arguments: + frame_img: The input image in BGR color space, assumed to have shape + (height, width, 3) where the last dimension represents the BGR channels. + bins: The number of bins to use for the histogram. + normalize: A boolean flag that determines whether the histogram should be + normalized such that the sum of all histogram bins equals 1. Returns: - -------- - np.ndarray - A 1D numpy array of length equal to `bins`, representing the histogram of the luma - channel. Each element in the array represents the count (or frequency) of a particular - luma value in the image. If normalized, these values represent the relative frequency. - - Examples: - --------- - >>> img = cv2.imread("path_to_image.jpg") - >>> hist = calculate_histogram(img, bins=256, normalize=True) - >>> print(hist.shape) - (256,) + A 1D numpy array of length equal to `bins`, representing the histogram of the + luma channel. Each element in the array represents the count (or frequency) of + a particular luma value in the image. If normalized, these values represent the + relative frequency. + + Example: + + .. code:: python + + img = cv2.imread("path_to_image.jpg") + hist = HistogramDetector.calculate_histogram(img, bins=256, normalize=True) + assert hist.shape == (256,) """ # Extract Luma channel from the frame image y, _, _ = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2YUV)) @@ -160,5 +164,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 2cfe05b8..945bc987 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. # @@ -22,7 +22,7 @@ import numpy -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike from scenedetect.detector import SceneDetector logger = getLogger("pyscenedetect") @@ -48,7 +48,7 @@ class Method(Enum): def __init__( self, threshold: float = 12, - min_scene_len: int = 15, + min_scene_len: TimecodeLike = 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 @@ -82,58 +83,46 @@ 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 = { - "frame": 0, # frame number where the last detected fade is + 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' } 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]: - """Process the next frame. `frame_num` is assumed to be sequential. + ) -> list[FrameTimecode]: + """Process the next frame. - Args: - frame_num (int): Frame number of frame that is being passed. Can start from any value - but must remain sequential. - frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. + Arguments: + timecode: FrameTimecode of the current frame position. + frame_img (numpy.ndarray or None): Video frame corresponding to `timecode`. Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. + List of FrameTimecodes where scene cuts have been detected. """ - # TODO(https://scenedetect.com/issue/168): We need to consider PTS here instead. The methods below using frame numbers - # won't work for variable framerates. - frame_num = timecode.frame_num - # Initialize last scene cut point at the beginning of the frames of interest. if self.last_scene_cut is None: - self.last_scene_cut = frame_num + self.last_scene_cut = timecode - # Compare the # of pixels under threshold in current_frame & last_frame. - # If absolute value of pixel intensity delta is above the threshold, - # then we trigger a new scene cut/break. - - # List of cuts to return. - cuts = [] + 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 # user-supplied values, we supply the average pixel intensity as this # frame metric instead (to assist with manually selecting a threshold) if (self.stats_manager is not None) and ( - self.stats_manager.metrics_exist(frame_num, self._metric_keys) + self.stats_manager.metrics_exist(timecode, self._metric_keys) ): - frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] + frame_avg = self.stats_manager.get_metrics(timecode, self._metric_keys)[0] else: frame_avg = numpy.mean(frame_img) if self.stats_manager is not None: @@ -146,34 +135,39 @@ def process_frame( ): # Just faded out of a scene, wait for next fade in. self.last_fade["type"] = "out" - self.last_fade["frame"] = frame_num + self.last_fade["frame"] = timecode elif self.last_fade["type"] == "out" and ( (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold) ): # Only add the scene if min_scene_len frames have passed. - if (frame_num - self.last_scene_cut) >= self.min_scene_len: + if (timecode - self.last_scene_cut) >= self.min_scene_len: # Just faded into a new scene, compute timecode for the scene - # split based on the fade bias. + # split based on the fade bias. Use frame-number arithmetic so the + # result is identical across backends - float seconds + framerate + # multiplication can land on a .5 rounding boundary and tip the + # frame number by 1 between PyAV (sub-microsecond PTS) and OpenCV + # (millisecond-truncated CAP_PROP_POS_MSEC). f_out = self.last_fade["frame"] - f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2 + duration_frames = timecode.frame_num - f_out.frame_num + split_frame_num = f_out.frame_num + round( + duration_frames * (1.0 + self.fade_bias) / 2.0 ) - cuts.append(f_split) - self.last_scene_cut = frame_num + cuts.append(FrameTimecode(split_frame_num, fps=timecode)) + self.last_scene_cut = timecode self.last_fade["type"] = "in" - self.last_fade["frame"] = frame_num + self.last_fade["frame"] = timecode else: - self.last_fade["frame"] = 0 + self.last_fade["frame"] = timecode if frame_avg < self.threshold: self.last_fade["type"] = "out" else: self.last_fade["type"] = "in" self.processed_frame = True - return [FrameTimecode(cut, fps=timecode) for cut in cuts] + return cuts - def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + 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 @@ -185,14 +179,13 @@ def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cuts = [] + cuts: 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_scene_cut is None and timecode >= self.min_scene_len) - or (timecode - self.last_scene_cut) >= self.min_scene_len - ) + and self.last_fade["frame"] is not None + and elapsed >= self.min_scene_len ): cuts.append(self.last_fade["frame"]) - return [FrameTimecode(cut, fps=timecode) for cut in cuts] + return cuts diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 752749cd..726b4d2d 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. # @@ -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, TimecodeLike from scenedetect.detector import FlashFilter, SceneDetector logger = getLogger("pyscenedetect") @@ -52,12 +49,12 @@ 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 + import onnxruntime as ort # pyright: ignore[reportMissingImports] ort.set_default_logger_severity(3) @@ -110,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 @@ -132,10 +131,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: int = 15, + min_scene_len: TimecodeLike = 15, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): super().__init__() @@ -163,9 +162,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 +186,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/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 3acd48f8..6fa26585 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. # @@ -16,8 +16,14 @@ """ import csv +import json import logging +import math 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,20 +34,35 @@ ) from scenedetect.common import ( CutList, + FrameTimecode, SceneList, ) # Commonly used classes/functions exported under the `scenedetect.output` namespace for brevity. -from scenedetect.output.image import save_images +from scenedetect.output.image import save_images as save_images from scenedetect.output.video import ( - PathFormatter, - SceneMetadata, - VideoMetadata, - default_formatter, - is_ffmpeg_available, - is_mkvmerge_available, - split_video_ffmpeg, - split_video_mkvmerge, + PathFormatter as PathFormatter, +) +from scenedetect.output.video import ( + SceneMetadata as SceneMetadata, +) +from scenedetect.output.video import ( + VideoMetadata as VideoMetadata, +) +from scenedetect.output.video import ( + default_formatter as default_formatter, +) +from scenedetect.output.video import ( + is_ffmpeg_available as is_ffmpeg_available, +) +from scenedetect.output.video import ( + is_mkvmerge_available as is_mkvmerge_available, +) +from scenedetect.output.video import ( + split_video_ffmpeg as split_video_ffmpeg, +) +from scenedetect.output.video import ( + split_video_mkvmerge as split_video_mkvmerge, ) logger = logging.getLogger("pyscenedetect") @@ -51,7 +72,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", ): @@ -75,7 +96,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:]] ) @@ -97,16 +118,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}", ] ) @@ -114,12 +135,12 @@ def write_scene_list( def write_scene_list_html( output_html_filename: str, scene_list: SceneList, - cut_list: ty.Optional[CutList] = None, - css: str = None, + cut_list: CutList | None = None, + css: str | None = 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. @@ -203,16 +224,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}", ] ) @@ -233,3 +254,421 @@ 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 + frame_rate = timecode.frame_rate + assert frame_rate is not None + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + frames_part = int((total_seconds * frame_rate) % frame_rate) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" + + +def _parse_edl_start_timecode(value: str, frame_rate: 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(frame_rate)) + 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(frame_rate)) + 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. + + 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. + 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: + frame_rate = scene_list[0][0].frame_rate + assert frame_rate is not None + offset_frames = _parse_edl_start_timecode(start_timecode, frame_rate) + lines = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""] + for i, (start, end) in enumerate(scene_list): + 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: + # `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: str | Path, + scene_list: SceneList, + video_path: str | Path, + frame_rate: Fraction, + 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). + + 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: str | Path, + scene_list: SceneList, + video_path: str | Path, + frame_rate: Fraction, + 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. + + 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: str | Path, + scene_list: SceneList, + video_path: str | Path, + frame_rate: Fraction, + name: str | None = 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/scenedetect/output/image.py b/scenedetect/output/image.py index 3fa54b04..b7fced6a 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. # @@ -27,19 +27,57 @@ FrameTimecode, Interpolation, SceneList, + TimecodeLike, ) -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") +def _generate_timecode_list( + scene_list: SceneList, + num_images: int, + frame_margin: TimecodeLike, +) -> list[list[FrameTimecode]]: + """Generate per-scene image timecodes using PTS-accurate seconds-based timing. + + `frame_margin` accepts any :data:`TimecodeLike` value (e.g. ``int`` frames, ``float`` + seconds, or ``str`` such as ``"0.1s"``). + """ + frame_rate = scene_list[0][0].frame_rate + assert frame_rate is not None + margin_secs = FrameTimecode(timecode=frame_margin, fps=frame_rate).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=frame_rate)) + result.append(timecodes) + return result + + def _scale_image( image: np.ndarray, - aspect_ratio: float, - height: ty.Optional[int], - width: ty.Optional[int], - scale: ty.Optional[float], + aspect_ratio: float | None, + height: int | None, + width: int | None, + scale: float | None, interpolation: Interpolation, ) -> np.ndarray: # TODO: Combine this resize with the ones below. @@ -55,9 +93,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: @@ -69,13 +109,13 @@ class _ImageExtractor: def __init__( self, num_images: int = 3, - frame_margin: int = 1, + frame_margin: TimecodeLike = 1, image_extension: str = "jpg", - imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, + imwrite_param: list[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 @@ -85,10 +125,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. @@ -118,15 +158,15 @@ 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, video: VideoStream, scene_list: SceneList, - output_dir: ty.Optional[str] = None, + output_dir: StrPath | 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: @@ -138,7 +178,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: @@ -157,7 +198,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), @@ -290,53 +331,18 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): if progress_bar is not None: progress_bar.update(1) - def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: + def generate_timecode_list(self, scene_list: SceneList) -> list[list[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config - parameters.""" - # TODO(v0.7): This needs to be fixed as part of PTS overhaul. - framerate = scene_list[0][0].framerate - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - return [ - ( - FrameTimecode(int(f), fps=framerate) - for f in ( - # middle frames - a[len(a) // 2] - if (0 < j < self._num_images - 1) or self._num_images == 1 - # first frame - else min(a[0] + self._frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - self._frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, self._num_images)) - ) - ) - for r in ( - # pad ranges to number of images - r - if 1 + r[-1] - r[0] >= self._num_images - else list(r) + [r[-1]] * (self._num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.frame_num, - start.frame_num - + max( - 1, # guard against zero length scenes - end.frame_num - start.frame_num, - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ) - ] + parameters. + + Uses PTS-accurate seconds-based timing so results are correct for both CFR and VFR video. + """ + return _generate_timecode_list(scene_list, self._num_images, self._frame_margin) 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 @@ -347,18 +353,18 @@ def save_images( scene_list: SceneList, video: VideoStream, num_images: int = 3, - frame_margin: int = 1, + frame_margin: TimecodeLike = 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: StrPath | 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. @@ -368,10 +374,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. @@ -409,8 +415,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. @@ -433,12 +441,13 @@ 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 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: @@ -451,45 +460,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 @@ -501,10 +472,10 @@ 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 = "%s.%s" % ( + file_path = "{}.{}".format( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), @@ -529,9 +500,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 737cb92d..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. # @@ -32,14 +32,21 @@ import logging import math -import subprocess import time import typing as ty +from collections.abc import Sequence 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,7 +59,9 @@ for details. Sorry about that! """ -_FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() +# 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).""" _DEFAULT_FFMPEG_ARGS = ( @@ -71,14 +80,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: @@ -119,27 +121,33 @@ class SceneMetadata: """Last frame.""" -PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], ty.AnyStr] +PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], str] def default_formatter(template: str) -> PathFormatter: """Formats filenames using a template string which allows the following variables: - `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME`, + `$START_PTS`, `$END_PTS` (presentation timestamp in milliseconds, accurate for VFR video) """ MIN_DIGITS = 3 - format_scene_number: PathFormatter = lambda video, scene: ( - ("%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), - ) + + 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 @@ -150,10 +158,10 @@ def default_formatter(template: str) -> PathFormatter: 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, + scene_list: Sequence[TimecodePair], + output_dir: str | Path | None = None, + output_file_template: str = "$VIDEO_NAME.mkv", + video_name: str | None = None, show_output: bool = False, suppress_output=None, ) -> int: @@ -211,12 +219,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, ] @@ -245,16 +254,16 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, - scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[Path] = None, + scene_list: Sequence[TimecodePair], + output_dir: str | 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`. @@ -304,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: @@ -323,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) @@ -347,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/platform.py b/scenedetect/platform.py index b832e250..9a783ea2 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. # @@ -15,7 +15,7 @@ functions to handle logging and invoking external commands. """ -import importlib +import importlib.metadata import logging import os import os.path @@ -24,10 +24,27 @@ import string import subprocess import sys -import typing as ty 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`).""" + +DEBUG_MODE: bool = os.environ.get("SCENEDETECT_DEBUG", "").strip().lower() not in ( + "", + "0", + "false", + "no", + "off", +) +"""True when the `SCENEDETECT_DEBUG` environment variable is set to a truthy value +(`1`, `true`, `yes`, `on`, etc.); False when unset or set to `0`/`false`/`no`/`off`/empty. +Use this to gate behavior intended only for development - e.g. re-raising unhandled +exceptions for debuggers/pytest instead of logging gracefully and exiting. Default-off so +end users on any install path (pip, pipx, the Windows .exe) get clean error output; pytest +opts in via `tests/conftest.py`.""" + ## ## tqdm Library ## @@ -36,7 +53,7 @@ class FakeTqdmObject: """Provides a no-op tqdm-like object.""" - def __init__(self, **kawrgs): + def __init__(self, **kwargs): """No-op.""" def update(self, n=1): @@ -52,7 +69,7 @@ def set_description(self, desc=None, refresh=True): class FakeTqdmLoggingRedirect: """Provides a no-op tqdm context manager for redirecting log messages.""" - def __init__(self, **kawrgs): + def __init__(self, **kwargs): """No-op.""" def __enter__(self): @@ -76,7 +93,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 +105,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: @@ -108,24 +125,19 @@ def _get_cv2_param(param_name: str) -> ty.Union[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.Optional[ty.AnyStr] = 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 +155,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) @@ -159,7 +172,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 +217,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 +246,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 +276,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 +290,17 @@ def get_ffmpeg_version() -> ty.Optional[str]: return output.splitlines()[0] -def get_mkvmerge_version() -> ty.Optional[str]: +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" try: @@ -292,65 +315,103 @@ def get_mkvmerge_version() -> ty.Optional[str]: return output.splitlines()[0] +def _query_package_version(dist_name: str, fallback_module: str | None) -> str | None: + """Return version of an installed package, querying PyPI metadata first then + falling back to the module's `__version__` attribute when metadata is missing. + + PyInstaller bundles ship modules but not the `.dist-info` directories that + `importlib.metadata` reads, so the fallback is required for frozen builds. + Returns None when the package isn't installed. + """ + try: + return importlib.metadata.version(dist_name) + except importlib.metadata.PackageNotFoundError: + pass + if fallback_module is None: + return None + try: + module = importlib.import_module(fallback_module) + except ModuleNotFoundError: + return None + return getattr(module, "__version__", None) + + def get_system_version_info() -> str: """Get the system's operating system, Python, packages, and external tool versions. Useful for debugging or filing bug reports. Used for the `scenedetect version -a` command. """ - output_template = "{:<16} {}" line_separator = "-" * 60 not_found_str = "Not Installed" out_lines = [] - # System (Python, OS) - output_template = "{:<16} {}" - out_lines += ["System Info", line_separator] - out_lines += [ - output_template.format(name, version) - for name, version in ( - ("OS", "%s" % platform.platform()), - ("Python", "%s %s" % (platform.python_implementation(), platform.python_version())), - ("Architecture", " + ".join(platform.architecture())), - ) - ] + system_info = ( + ("OS", f"{platform.platform()}"), + ("Python", f"{platform.python_implementation()} {platform.python_version()}"), + ("Architecture", " + ".join(platform.architecture())), + ) - # Third-Party Packages - out_lines += ["", "Packages", line_separator] + # Third-Party Packages: queried via PyPI distribution names with a module-attribute + # fallback. PyInstaller bundles ship the modules but not the `.dist-info` metadata + # directories, so `importlib.metadata.version()` alone reports "Not Installed" for + # every package in a frozen build; reading `module.__version__` recovers the version + # there. `scenedetect` is read from the package attribute since it must report a + # version even when run uninstalled (e.g. from a source checkout). The import is + # deferred to avoid a circular import at module load time. + from scenedetect import __version__ as scenedetect_version + + # (dist_name, fallback_module_name). Module fallback is only used when that dist's + # metadata is missing. Known quirk: `cv2` cannot reveal which distribution provided + # it, so whenever cv2 is importable but `opencv-python-headless` is not installed + # (e.g. only `opencv-python` is), the headless row still shows cv2's version via the + # fallback - both opencv rows then report a version even though only one is + # installed. Kept intentionally: the fallback is what recovers the version in the + # frozen Windows build (which ships cv2 without any `.dist-info`), and the + # `opencv-python` row is metadata-only, so it remains accurate on its own. + # The same code ships in the `scenedetect`/`scenedetect-headless` distributions + # (OpenCV variant + CLI deps). `scenedetect-core` was published in 0.7.1 only and + # then yanked (see https://scenedetect.com/issues/558); its row is kept so lingering + # installs remain visible. Metadata-only lookups (no module fallback) so each row + # reflects which distribution is actually installed - e.g. frozen builds show + # "Not Installed" here rather than misattributing the module version. + scenedetect_packages = ( + ("scenedetect-core", None), + ("scenedetect-headless", None), + ) third_party_packages = ( - "av", - "click", - "cv2", - "imageio", - "imageio_ffmpeg", - "moviepy", - "numpy", - "platformdirs", - "scenedetect", - "tqdm", + ("av", "av"), + ("click", "click"), + ("opencv-python", None), + ("opencv-python-headless", "cv2"), + ("imageio", "imageio"), + ("imageio-ffmpeg", "imageio_ffmpeg"), + ("moviepy", "moviepy"), + ("numpy", "numpy"), + ("platformdirs", "platformdirs"), + ("tqdm", "tqdm"), ) - for module_name in third_party_packages: - try: - module = importlib.import_module(module_name) - if hasattr(module, "__version__"): - out_lines.append(output_template.format(module_name, module.__version__)) - else: - out_lines.append(output_template.format(module_name, not_found_str)) - except ModuleNotFoundError: - out_lines.append(output_template.format(module_name, not_found_str)) - - # External Tools - out_lines += ["", "Tools", line_separator] + package_versions = [("scenedetect", scenedetect_version)] + [ + (dist_name, _query_package_version(dist_name, fallback_module) or not_found_str) + for dist_name, fallback_module in (*scenedetect_packages, *third_party_packages) + ] - tool_version_info = ( - ("ffmpeg", get_ffmpeg_version()), - ("mkvmerge", get_mkvmerge_version()), + tool_versions = ( + ("ffmpeg", get_ffmpeg_version() or not_found_str), + ("mkvmerge", get_mkvmerge_version() or not_found_str), ) - for tool_name, tool_version in tool_version_info: - out_lines.append( - output_template.format(tool_name, tool_version if tool_version else not_found_str) - ) + # Size the label column to the longest label across every section so all three tables + # align consistently - `opencv-python-headless` exceeds the previous fixed width of 16. + label_width = max(len(name) for name, _ in (*system_info, *package_versions, *tool_versions)) + output_template = f"{{:<{label_width}}} {{}}" + + out_lines += ["System Info", line_separator] + out_lines += [output_template.format(name, value) for name, value in system_info] + out_lines += ["", "Packages", line_separator] + out_lines += [output_template.format(name, value) for name, value in package_versions] + out_lines += ["", "Tools", line_separator] + out_lines += [output_template.format(name, value) for name, value in tool_versions] return "\n".join(out_lines) 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 8b47b32b..05ba8dc1 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. # @@ -92,6 +92,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): FrameTimecode, Interpolation, SceneList, + TimecodeLike, ) from scenedetect.detector import SceneDetector @@ -139,10 +140,38 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI return frame_width / float(effective_width) +def expand_scenes_to_bounds( + scenes: SceneList, + start: FrameTimecode, + end: FrameTimecode, +) -> SceneList: + """Return a new scene list whose first scene starts at `start` and last scene ends at `end`. + + Useful when scenes were detected within a sub-region of a video (e.g. via the `time` + command's `-s`/`-e`) but the caller wants the resulting clip boundaries to cover content + outside that analysis window. + + Arguments: + scenes: List of (start, end) FrameTimecode pairs. + start: Desired start of the first scene. + end: Desired end of the last scene. + + Returns: + A new scene list with the outer endpoints replaced. The input is not modified. + An empty input is returned unchanged. + """ + if not scenes: + return list(scenes) + expanded = list(scenes) + expanded[0] = (start, expanded[0][1]) + expanded[-1] = (expanded[-1][0], end) + return expanded + + 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,44 +223,42 @@ 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. # 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 + # to the detect_scenes method. Concatenation is handled by VideoStreamConcat + # (scenedetect.backends.concat). + 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: ty.Tuple[int, int] = None + self._frame_size: tuple[int, int] | None = 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 # as a good balance between quality and performance. self._interpolation: Interpolation = Interpolation.LINEAR - # Boolean indicating if we have only seen EventType.CUT events so far. - self._only_cuts: bool = True # Set by decode thread when an exception occurs. 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 +272,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, @@ -361,7 +388,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( @@ -373,7 +400,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 +411,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.""" @@ -399,12 +426,12 @@ 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: if cut == position: - callback(frame, int(position)) + callback(frame, position) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: @@ -418,13 +445,13 @@ def stop(self) -> None: def detect_scenes( self, - video: VideoStream = None, - duration: ty.Optional[FrameTimecode] = None, - end_time: ty.Optional[FrameTimecode] = None, + video: VideoStream | None = None, + duration: TimecodeLike | None = None, + end_time: TimecodeLike | 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, FrameTimecode], 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 @@ -457,7 +484,7 @@ def detect_scenes( ValueError: `frame_skip` **must** be 0 (the default) if the SceneManager was constructed with a StatsManager object. """ - # TODO(v0.7): Add DeprecationWarning that `frame_source` will be removed in v0.8. + # TODO(v0.8): Remove `frame_source` entirely; the `DeprecationWarning` below has shipped. if frame_source is not None: warnings.warn( "The `frame_source` argument is deprecated, use `video` instead.", @@ -480,7 +507,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)) @@ -542,35 +571,51 @@ def detect_scenes( ) decode_thread.start() frame_im = None + prev_position = None logger.info("Detecting scenes...") - while not self._stop.is_set(): - next_frame, position = frame_queue.get() - if next_frame is None and position is None: - break - if next_frame is not None: - frame_im = next_frame - new_cuts = self._process_frame(position, frame_im, callback) - if progress_bar is not None: - if new_cuts: - progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False + try: + while not self._stop.is_set(): + next_frame, position = frame_queue.get() + if next_frame is None and position is None: + 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: + progress_bar.set_description( + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False + ) + # Increment progress bar by delta of position.frame_num instead of 1 + # to handle VFR video where frame count is an approximation. + delta = ( + 1 if prev_position is None else position.frame_num - prev_position.frame_num ) - progress_bar.update(1 + frame_skip) - - if progress_bar is not None: - progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True - ) - progress_bar.close() - # Unblock any puts in the decode thread before joining. This can happen if the main - # processing thread stops before the decode thread. - while not frame_queue.empty(): - frame_queue.get_nowait() - decode_thread.join() + progress_bar.update(delta) + prev_position = position + finally: + if progress_bar is not None: + progress_bar.set_description( + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True + ) + progress_bar.close() + # The decode thread must never be abandoned, even if a detector or callback + # raises above: an orphaned daemon thread keeps the VideoStream alive until + # interpreter shutdown, where finalizing it (or killing the thread mid-decode) + # can crash process exit. Signal it to stop, then keep unblocking any pending + # puts until it exits. + self._stop.set() + while decode_thread.is_alive(): + while not frame_queue.empty(): + frame_queue.get_nowait() + decode_thread.join(timeout=0.1) 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 +639,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]) @@ -608,7 +654,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 61e67970..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. # @@ -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,19 +96,20 @@ 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: 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: ty.Dict[FrameTimecode, ty.Dict[str, float]] = dict() - self._metric_keys: ty.Set[str] = set() + # 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: ty.Optional[FrameTimecode] = ( + self._base_timecode: int | FrameTimecode | None = ( base_timecode # Used for timing calculations. ) @@ -121,8 +124,8 @@ 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]: + self, timecode: int | FrameTimecode, metric_keys: ty.Iterable[str] + ) -> list[ty.Any]: """Return the requested statistics/metrics for a given timecode. Returns: @@ -131,7 +134,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: int | FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: @@ -141,7 +144,7 @@ def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: ty.Dict[str, ty.A 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: @@ -160,7 +163,7 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: ty.Union[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. @@ -178,24 +181,29 @@ 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)) - 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: + # `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)] ) @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: @@ -206,13 +214,11 @@ def valid_header(row: ty.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. - def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optional[int]: + 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 @@ -236,7 +242,7 @@ def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optiona # 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) @@ -281,7 +287,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,16 +297,18 @@ 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: 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/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 26c2cefe..71af9950 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. # @@ -33,12 +33,11 @@ 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, TimecodeLike class SeekError(Exception): @@ -67,7 +66,7 @@ class FrameRateUnavailable(VideoOpenFailure): def __init__(self): super().__init__( - "Unable to obtain video framerate! Specify `framerate` manually, or" + "Unable to obtain video framerate! Specify `frame_rate` manually, or" " re-encode/re-mux the video and try again." ) @@ -84,21 +83,27 @@ class VideoStream(ABC): # Default Implementations # + _decode_failures: int = 0 + """Cumulative count of frames which failed to decode. Backends which can detect and skip + corrupt frames increment this as an instance attribute.""" + @property def base_timecode(self) -> FrameTimecode: """FrameTimecode object to use as a time base.""" return FrameTimecode(timecode=0, fps=self.frame_rate) + @property + def decode_failures(self) -> int: + """Number of frames that failed to decode and were skipped (may indicate video + corruption). Always 0 for backends which do not track decode failures.""" + return self._decode_failures + # - # 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 @@ -106,13 +111,13 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> ty.Union[bytes, str]: + def path(self) -> str: """Video or device path.""" ... @property @abstractmethod - def name(self) -> ty.Union[bytes, str]: + def name(self) -> str: """Name of the video, without extension, or device.""" ... @@ -124,19 +129,19 @@ def is_seekable(self) -> bool: @property @abstractmethod - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction (e.g. Fraction(24000, 1001)).""" ... @property @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 +180,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 +200,7 @@ def reset(self) -> None: ... @abstractmethod - def seek(self, target: ty.Union[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). diff --git a/scripts/_release_common.py b/scripts/_release_common.py new file mode 100644 index 00000000..24392b52 --- /dev/null +++ b/scripts/_release_common.py @@ -0,0 +1,121 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Shared helpers for Windows release-finalization and validation scripts.""" + +import hashlib +import re +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +CHUNK = 1 << 20 # 1 MiB + + +def msi_version(raw: str) -> str: + # AdvancedInstaller's MSI ProductVersion field requires numeric X.Y.Z[.B]; + # strip Python-style suffixes ("0.7-dev0" -> "0.7") and pad to three parts. + # Use this ONLY for the /SetVersion value passed to AdvancedInstaller, not + # for artifact filenames - those should use display_version() to match the + # Python package version (e.g. PyPI "0.7", not "0.7.0"). + parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")] + while len(parts) < 3: + parts.append("0") + return ".".join(parts[:4]) + + +def display_version(raw: str) -> str: + # Filename-facing version: matches scenedetect.__version__ component count, + # with Python-style suffixes stripped ("0.7-dev0" -> "0.7", "0.7" -> "0.7", + # "0.7.1" -> "0.7.1"). Use for .msi/.zip/manifest filenames so artifacts + # line up with the PyPI package and git tag. + parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")] + return ".".join(p for p in parts[:4] if p) + + +def find_7zip() -> Path: + for candidate in ( + Path(r"C:\Program Files\7-Zip\7z.exe"), + Path(r"C:\Program Files (x86)\7-Zip\7z.exe"), + ): + if candidate.exists(): + return candidate + on_path = shutil.which("7z") or shutil.which("7z.exe") + if on_path: + return Path(on_path) + sys.exit("7-Zip not found. Install from https://www.7-zip.org/.") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for block in iter(lambda: f.read(CHUNK), b""): + h.update(block) + return h.hexdigest() + + +def hash_zip_contents(zip_path: Path) -> list[dict]: + entries = [] + with zipfile.ZipFile(zip_path) as zf: + for info in sorted(zf.infolist(), key=lambda i: i.filename): + if info.is_dir(): + continue + h = hashlib.sha256() + with zf.open(info) as f: + for block in iter(lambda: f.read(CHUNK), b""): + h.update(block) + entries.append( + { + "path": info.filename, + "size": info.file_size, + "sha256": h.hexdigest(), + } + ) + return entries + + +def verify_authenticode(path: Path) -> None: + """Bail unless `path` carries a Valid Authenticode signature. + + Catches the wrong-artifact case: e.g. someone drops the AppVeyor + pre-signing bundle into dist/signed/ instead of the SignPath output. + PowerShell's Get-AuthenticodeSignature works on both .exe and .msi. + """ + if sys.platform != "win32": + print(f" (skipping Authenticode check for {path.name} on non-Windows)") + return + ps_cmd = ( + f"$sig = Get-AuthenticodeSignature -FilePath '{path}'; " + "Write-Output $sig.Status; " + "if ($sig.SignerCertificate) { Write-Output $sig.SignerCertificate.Subject }" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + check=False, + ) + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if result.returncode != 0 or not lines: + sys.exit( + f"Authenticode check for {path.name} failed to run.\n stderr: {result.stderr.strip()}" + ) + status = lines[0] + subject = lines[1] if len(lines) > 1 else "" + print(f" Authenticode: {status} ({subject})") + if status != "Valid": + sys.exit( + f"Authenticode check FAILED for {path.name}: status={status!r}. " + "Verify scenedetect-signed.zip is the SignPath output, not an " + "unsigned AppVeyor artifact." + ) diff --git a/scripts/benchmark_defaults.sh b/scripts/benchmark_defaults.sh new file mode 100644 index 00000000..39a41fbb --- /dev/null +++ b/scripts/benchmark_defaults.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Run every detector against every dataset at default kwargs. +# JSON + log per cell are written under benchmark/results/defaults/. +# +# Environment overrides: +# DATASET_ROOT Base directory containing per-dataset subfolders (BBC/, AutoShot/, ClipShots/). +# Defaults to the in-repo benchmark/ folder; override when datasets live +# elsewhere (e.g. DATASET_ROOT=D:/path/to/benchmark scripts/benchmark_defaults.sh). +# OUT_DIR Where to write results. Defaults to benchmark/results/defaults. +# PY Python interpreter. Defaults to python on PATH. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DATASET_ROOT="${DATASET_ROOT:-$REPO_ROOT/benchmark}" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/benchmark/results/defaults}" +PY="${PY:-python}" + +DETECTORS=(detect-adaptive detect-content detect-hash detect-hist detect-threshold) +DATASETS=(BBC AutoShot ClipShots) + +mkdir -p "$OUT_DIR" +for det in "${DETECTORS[@]}"; do + for ds in "${DATASETS[@]}"; do + "$PY" -m benchmark --detector "$det" --dataset "$ds" \ + --dataset-root "$DATASET_ROOT" --tolerance 0,1 \ + --out "$OUT_DIR/$det-$ds.json" | tee "$OUT_DIR/$det-$ds.log" + done +done diff --git a/scripts/benchmark_sweep.sh b/scripts/benchmark_sweep.sh new file mode 100644 index 00000000..51a8e355 --- /dev/null +++ b/scripts/benchmark_sweep.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Overnight parameter sweep across detectors x datasets. +# JSON + log per cell are written under benchmark/results/sweep/. +# +# Grids are sized to fit comfortably in ~8-10 hours on a reasonably fast machine +# with --workers=16. Tune DETECTORS / DATASETS / WORKERS via env to subset. +# A failed (det, ds) pair logs a warning and continues; check the final summary. +# +# Environment overrides: +# DATASET_ROOT Base directory containing per-dataset subfolders (BBC/, AutoShot/, ClipShots/). +# Defaults to the in-repo benchmark/ folder. +# OUT_DIR Where to write results. Defaults to benchmark/results/sweep. +# WORKERS Parallel detectors per video decode (default: 16). Memory ~= workers * 24MB. +# QUICK If set to N, limits each dataset to first N samples (smoke-test override). +# PY Python interpreter. Defaults to python on PATH. +# DETECTORS Space-separated subset; defaults to all five sweep-supported detectors. +# DATASETS Space-separated subset; defaults to BBC AutoShot ClipShots. +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DATASET_ROOT="${DATASET_ROOT:-$REPO_ROOT/benchmark}" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/benchmark/results/sweep}" +WORKERS="${WORKERS:-16}" +PY="${PY:-python}" +DETECTORS="${DETECTORS:-detect-content detect-adaptive detect-hash detect-hist}" +DATASETS="${DATASETS:-BBC AutoShot ClipShots}" + +QUICK_FLAG="" +if [[ -n "${QUICK:-}" ]]; then + QUICK_FLAG="--quick $QUICK" +fi + +# Per-detector grid. Hits the most impactful axes per detector at a coarse enough +# step to fit in an overnight run. Use the per-detector outputs to design a finer +# follow-up sweep around the winning cell. +grid_for() { + case "$1" in + detect-content) + echo "threshold=15:35:2;min_scene_len=0.0,0.2,0.4,0.6,0.8" + ;; + detect-adaptive) + echo "adaptive_threshold=1.5:6.0:0.5;min_scene_len=0.4,0.6;window_width=2,3" + ;; + detect-hash) + echo "threshold=0.25:0.55:0.025;size=8,16" + ;; + detect-hist) + echo "threshold=0.02:0.35:0.01;bins=128,256" + ;; + *) + echo "" + ;; + esac +} + +mkdir -p "$OUT_DIR" +# SUMMARY_LOG lets several concurrent (detector-parallel) runs keep separate +# summaries while sharing OUT_DIR for the per-pair JSON outputs. +SUMMARY="${SUMMARY_LOG:-$OUT_DIR/_summary.log}" +echo "Sweep started: $(date -Iseconds)" | tee -a "$SUMMARY" +echo "DATASET_ROOT=$DATASET_ROOT" | tee -a "$SUMMARY" +echo "WORKERS=$WORKERS" | tee -a "$SUMMARY" +echo | tee -a "$SUMMARY" + +for det in $DETECTORS; do + spec="$(grid_for "$det")" + if [[ -z "$spec" ]]; then + echo "SKIP $det -- no grid defined" | tee -a "$SUMMARY" + continue + fi + for ds in $DATASETS; do + out_json="$OUT_DIR/$det-$ds.json" + log_file="$OUT_DIR/$det-$ds.log" + if [[ -s "$out_json" ]]; then + echo "SKIP $det on $ds -- $out_json already exists" | tee -a "$SUMMARY" + continue + fi + started="$(date +%s)" + echo "RUN $det on $ds [$spec]" | tee -a "$SUMMARY" + if "$PY" -m benchmark.sweep \ + --detector "$det" --dataset "$ds" \ + --dataset-root "$DATASET_ROOT" \ + --params "$spec" \ + --tolerance 0,1 \ + --workers "$WORKERS" \ + $QUICK_FLAG \ + --out "$out_json" 2>&1 | tee "$log_file"; then + elapsed=$(( $(date +%s) - started )) + echo "OK $det on $ds in ${elapsed}s" | tee -a "$SUMMARY" + else + elapsed=$(( $(date +%s) - started )) + echo "FAIL $det on $ds after ${elapsed}s (see $log_file)" | tee -a "$SUMMARY" + fi + done +done + +echo | tee -a "$SUMMARY" +echo "Sweep complete: $(date -Iseconds)" | tee -a "$SUMMARY" diff --git a/scripts/finalize_windows_dist.py b/scripts/finalize_windows_dist.py new file mode 100644 index 00000000..a9e4cc36 --- /dev/null +++ b/scripts/finalize_windows_dist.py @@ -0,0 +1,228 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Finalize signed Windows release artifacts. + +Takes the signed bundle returned by SignPath, extracts the file tree from the +signed MSI via `msiexec /a`, repacks it as the portable .zip with 7-Zip, and +emits SHA256 manifests over the final release artifacts. + +Run after the SignPath signing job completes and `scenedetect-signed.zip` +has been downloaded. + +Expected input (in --staging-dir, default `dist/signed/`): + scenedetect-signed.zip - SignPath bundle (signed .exe + .msi) + +Outputs (written to the same directory): + PySceneDetect-X.Y.Z-win64.zip - portable .zip rebuilt from the signed MSI + PySceneDetect-X.Y.Z-win64.msi - signed MSI extracted from the bundle + PySceneDetect-X.Y.Z-win64.manifest.json - structured per-file SHA256 manifest + SHA256SUMS - flat sha256sum -c compatible output +""" + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import validate_release # noqa: E402 +from _release_common import ( # noqa: E402 + display_version, + find_7zip, + hash_zip_contents, + sha256_file, + verify_authenticode, +) + +import scenedetect # noqa: E402 + +VERSION = display_version(scenedetect.__version__) + + +def extract_signed_bundle(signed_zip: Path, dest: Path) -> tuple[Path, Path]: + print(f"Extracting {signed_zip.name}...") + with zipfile.ZipFile(signed_zip) as zf: + zf.extractall(dest) + exe = next((p for p in dest.rglob("scenedetect.exe")), None) + msi = next((p for p in dest.rglob("PySceneDetect-*.msi")), None) + if exe is None: + sys.exit(f"scenedetect.exe not found inside {signed_zip}") + if msi is None: + sys.exit(f"PySceneDetect-*.msi not found inside {signed_zip}") + print(f" signed exe: {exe.name} ({exe.stat().st_size:,} bytes)") + verify_authenticode(exe) + print(f" signed msi: {msi.name} ({msi.stat().st_size:,} bytes)") + verify_authenticode(msi) + return exe, msi + + +def extract_msi_tree(msi_path: Path, dest: Path) -> Path: + """Run `msiexec /a` to extract the .msi's installed file tree without + actually installing. Returns the directory containing scenedetect.exe + (the app root), which sits under TARGETDIR at the .aip's APPDIR depth.""" + if sys.platform != "win32": + sys.exit("msiexec /a is Windows-only") + print(f"Extracting {msi_path.name} via msiexec /a...") + # /a = administrative install: file extraction only, no registry, no admin rights. + # /qn = silent. TARGETDIR must be absolute. + result = subprocess.run( + ["msiexec", "/a", str(msi_path), "/qn", f"TARGETDIR={dest}"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.exit( + f"msiexec /a failed (exit {result.returncode}): " + f"{result.stderr.strip() or result.stdout.strip()}" + ) + exe = next((p for p in dest.rglob("scenedetect.exe")), None) + if exe is None: + sys.exit(f"scenedetect.exe not found anywhere under {dest} after msiexec /a") + tree = exe.parent + # `msiexec /a` writes an "administrative" copy of the .msi (and sometimes a + # `Cabs/` folder) into TARGETDIR alongside the extracted app files. When + # APPDIR == TARGETDIR (no nested install folder), these land inside the app + # tree and would pollute the portable .zip. Strip them. + for stray in tree.glob("*.msi"): + print(f" stripping admin-install artifact: {stray.name}") + stray.unlink() + cabs_dir = tree / "Cabs" + if cabs_dir.is_dir(): + print(" stripping admin-install artifact: Cabs/") + shutil.rmtree(cabs_dir) + print(f" app tree: {tree.relative_to(dest)}/ ({sum(1 for _ in tree.rglob('*')):,} entries)") + return tree + + +def build_portable_zip(tree: Path, zip_path: Path, sevenz: Path) -> None: + """Pack `tree`'s top-level contents into a Deflate .zip using the same + flags AppVeyor's stage_windows_dist.py uses for the portable distribution.""" + if zip_path.exists(): + zip_path.unlink() + print(f"Building {zip_path.name} (zip / Deflate / mx=9 / mt=on)...") + # -mm=Deflate (not LZMA): Windows Explorer's built-in "Extract All" only + # supports Deflate-compressed zips; LZMA needs 7-Zip/WinRAR. Portable .zip + # ships to end users on clean Windows, so compat trumps ratio here. + # -mfb=258 -mpass=15: max-out Deflate tuning (slow, but once per release). + # -mmt=on: 7z parallelizes Deflate across files (not within a file), so + # the docs/ + thirdparty/ tree gets a real speedup; the two big binaries + # (scenedetect.exe, ffmpeg.exe) still each compress on a single thread. + # Pass top-level entries (not '*') so we don't depend on shell globbing. + entries = sorted(p.name for p in tree.iterdir()) + subprocess.run( + [ + str(sevenz), + "a", + "-tzip", + "-mm=Deflate", + "-mx=9", + "-mfb=258", + "-mpass=15", + "-mmt=on", + str(zip_path), + *entries, + ], + cwd=tree, + check=True, + capture_output=True, + ) + print(f" {zip_path.stat().st_size / (1024 * 1024):.1f} MB") + + +def write_manifests(staging: Path, portable_zip: Path, msi: Path) -> None: + print(f"Hashing {portable_zip.name}...") + portable_digest = sha256_file(portable_zip) + print(f"Hashing {msi.name}...") + msi_digest = sha256_file(msi) + + manifest = { + "version": VERSION, + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "bundles": { + "msi": { + "path": msi.name, + "size": msi.stat().st_size, + "sha256": msi_digest, + }, + "portable_zip": { + "path": portable_zip.name, + "size": portable_zip.stat().st_size, + "sha256": portable_digest, + "contents": hash_zip_contents(portable_zip), + }, + }, + } + + manifest_path = staging / f"PySceneDetect-{VERSION}-win64.manifest.json" + sums_path = staging / "SHA256SUMS" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + sums_path.write_text( + f"{msi_digest} {msi.name}\n{portable_digest} {portable_zip.name}\n", + encoding="utf-8", + ) + print(f"Wrote {manifest_path.name}") + print(f"Wrote {sums_path.name}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "--staging-dir", + type=Path, + default=REPO_DIR / "dist" / "signed", + help="Directory holding scenedetect-signed.zip.", + ) + args = parser.parse_args() + + staging = args.staging_dir.resolve() + if not staging.is_dir(): + sys.exit(f"{staging} not found") + + signed_bundle = staging / "scenedetect-signed.zip" + if not signed_bundle.is_file(): + sys.exit(f"{signed_bundle} not found") + + sevenz = find_7zip() + print(f"Using 7-Zip: {sevenz}") + print(f"Staging dir: {staging}") + print(f"Version: {VERSION}") + + portable_zip = staging / f"PySceneDetect-{VERSION}-win64.zip" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + # Bundle holds the SignPath outputs; signed .exe is verified for the + # wrong-bundle check but otherwise unused (the .msi already ships its + # own signed copy of scenedetect.exe). + _signed_exe, signed_msi = extract_signed_bundle(signed_bundle, tmp_path / "bundle") + msi_dest = staging / signed_msi.name + shutil.copy2(signed_msi, msi_dest) + print(f"Copied signed MSI -> {msi_dest.name}") + msi_tree = extract_msi_tree(msi_dest, tmp_path / "msi-extract") + build_portable_zip(msi_tree, portable_zip, sevenz) + write_manifests(staging, portable_zip, msi_dest) + + print() + print("Validating finalized artifacts...") + validate_release.run_all_checks(staging) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py new file mode 100644 index 00000000..7cac1df3 --- /dev/null +++ b/scripts/generate_assets.py @@ -0,0 +1,405 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Generate pyscenedetect.ico, logo PNGs, and Windows installer branding from SVG sources. + +Outputs: + - icons: packaging/windows/pyscenedetect.ico, docs/_static/favicon.ico, + website/pages/img/favicon.ico + - logos: docs/_static/, website/pages/img/ + - installer: psd_square_small.ico, installer_banner.{svg,png}, installer_logo.{svg,png} and + scale variants for .msi creation + +Usage: + python scripts/generate_assets.py + +Requires Inkscape and Pillow. +""" + +import argparse +import contextlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NamedTuple + +from PIL import Image, ImageDraw, ImageFilter + + +class LogoOutput(NamedTuple): + path: Path + width: int + height: int + source: Path + + +# Colors matching the SVG design +BG = (224, 232, 240, 255) # #e0e8f0 +FG = (42, 53, 69, 255) # #2a3545 + +RASTER_SIZES = [16, 24, 32, 48, 64, 128, 256] + +SHARPEN_AMOUNT = { + 24: 75, + 32: 75, + 48: 75, + 64: 100, + 128: 150, + 256: 150, +} + +SHARPEN_RADIUS = 0.5 + +REPO_DIR = Path(__file__).resolve().parent.parent +PACKAGING_DIR = REPO_DIR / "packaging" +LOGO_DIR = PACKAGING_DIR / "logo" +ICO_PATH = PACKAGING_DIR / "windows" / "pyscenedetect.ico" + +LOGO_SVG = LOGO_DIR / "pyscenedetect-logo.svg" +LOGO_BG_SVG = LOGO_DIR / "pyscenedetect-logo-bg.svg" +SLATE_SVG = LOGO_DIR / "pyscenedetect.svg" # slate-only icon (256x256) + +INSTALLER_DIR = PACKAGING_DIR / "windows" / "installer" +GENERATED_IMAGES_DIR = INSTALLER_DIR / "Generated Images" +ARP_ICO_PATH = INSTALLER_DIR / "psd_square_small.ico" + +# Classic AdvancedInstaller theme: brand mark on a colored panel. +# Banner is full-bleed light blue (BG) with the FG-bodied slate on the right; +# dialog is white with a dark (FG) strip on the left holding the inverted +# (BG-bodied) slate. +BANNER_BASE = (493, 58) +DIALOG_BASE = (493, 312) +DIALOG_STRIP_FRAC = 1.0 / 3.0 # left strip width as fraction of dialog width +BANNER_ICON_FRAC = 0.75 # icon side as fraction of banner height +DIALOG_ICON_FRAC = 0.55 # icon side as fraction of dialog strip width +SCALES: list[tuple[float, str]] = [ + (1.00, ""), + (1.25, ".scale-125"), + (1.50, ".scale-150"), + (2.00, ".scale-200"), +] +TOP_LEVEL_BANNER_PNG_SIZE = (1634, 211) +TOP_LEVEL_DIALOG_PNG_SIZE = (647, 407) + +# Heights match the natural SVG aspect ratio (1024x480). +# _small outputs use the -bg variant (background included). +FAVICON_OUTPUTS: list[Path] = [ + REPO_DIR / "docs" / "_static" / "favicon.ico", + REPO_DIR / "website" / "pages" / "img" / "favicon.ico", +] + +LOGO_OUTPUTS: list[LogoOutput] = [ + LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo.png", 900, 422, LOGO_SVG), + LogoOutput( + REPO_DIR / "docs" / "_static" / "pyscenedetect_logo_small.png", 300, 141, LOGO_BG_SVG + ), + LogoOutput( + REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo.png", 640, 300, LOGO_BG_SVG + ), + LogoOutput( + REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo_small.png", 462, 217, LOGO_SVG + ), +] + +SVG_FOR_SIZE: dict[int, Path] = { + 24: LOGO_DIR / "pyscenedetect-24.svg", + 32: LOGO_DIR / "pyscenedetect-32.svg", + 48: LOGO_DIR / "pyscenedetect.svg", + 64: LOGO_DIR / "pyscenedetect.svg", + 128: LOGO_DIR / "pyscenedetect.svg", + 256: LOGO_DIR / "pyscenedetect.svg", +} + + +def make_icon_16() -> Image.Image: + """Create a hand-crafted 16x16 clapperboard icon.""" + img = Image.new("RGBA", (16, 16), FG) + px = img.load() + assert px is not None + + # Clear 1px padding on all sides + for i in range(16): + px[0, i] = BG + px[15, i] = BG + px[i, 0] = BG + px[i, 15] = BG + + # Arm stripe gaps (rows 2-4): clear pixels not part of a complete stripe. + # A stripe x+y=s spans all 3 arm rows only when 5 <= s <= 16. + for y in range(2, 5): + for x in range(1, 15): + if y < 4 and x < 3: + continue + if y > 2 and x > 12: + continue + if not ((x + y) % 4 < 2 and 5 <= (x + y) <= 16): + px[x, y] = BG + + # Slate interior (rows 8-12, cols 3-12) + for y in range(8, 13): + for x in range(3, 13): + px[x, y] = BG + + return img + + +def find_inkscape() -> str: + """Find the Inkscape executable.""" + inkscape = shutil.which("inkscape") + if inkscape: + return inkscape + # Common Windows install path + candidate = Path(r"C:\Program Files\Inkscape\bin\inkscape.exe") + if candidate.exists(): + return str(candidate) + print("Error: Inkscape not found. Please install it or add it to PATH.", file=sys.stderr) + sys.exit(1) + + +def render_svg(inkscape: str, svg: Path, output: Path, width: int, height: int): + """Render an SVG to a PNG at the given dimensions using Inkscape.""" + subprocess.run( + [ + inkscape, + str(svg), + "--export-type=png", + f"--export-filename={output}", + "-w", + str(width), + "-h", + str(height), + ], + check=True, + capture_output=True, + ) + + +def render_logos(inkscape: str): + """Render the logo SVG to all required PNG outputs.""" + print("Rendering logo PNGs...") + for entry in LOGO_OUTPUTS: + rel_path = entry.path.relative_to(REPO_DIR) + print(f" {rel_path} ({entry.width}x{entry.height}) [source: {entry.source.name}]...") + render_svg(inkscape, entry.source, entry.path, entry.width, entry.height) + print(f" Done ({len(LOGO_OUTPUTS)} files).") + + +def _render_slate(inkscape: str, work_dir: Path, side: int, *, inverted: bool) -> Image.Image: + """Render the slate icon at exact size with Inkscape. + + With inverted=False, the slate renders with its native FG body / BG stripes + (right for placing on the white banner). With inverted=True, the SVG color + codes are swapped before rendering so the body becomes BG and the stripes + FG - needed for the dialog's dark FG strip, where a non-inverted slate + would blend into the background. + """ + if inverted: + sentinel = "__SWAP_FG__" + svg_text = SLATE_SVG.read_text(encoding="utf-8") + svg_text = ( + svg_text.replace("#2a3545", sentinel) + .replace("#e0e8f0", "#2a3545") + .replace(sentinel, "#e0e8f0") + ) + svg_path = work_dir / f"slate_inv_{side}.svg" + svg_path.write_text(svg_text, encoding="utf-8") + else: + svg_path = SLATE_SVG + out = work_dir / f"slate_{'inv_' if inverted else ''}{side}.png" + render_svg(inkscape, svg_path, out, side, side) + return Image.open(out).convert("RGBA") + + +def _save_baseline_jpeg(img: Image.Image, path: Path) -> None: + """Save as baseline (non-progressive) sRGB JPEG. Required by Windows Installer's + dialog renderer; progressive JPEGs decode as solid black at install time.""" + img.convert("RGB").save( + path, "JPEG", quality=92, optimize=True, progressive=False, subsampling=0 + ) + + +def _compose_banner(slate_fg: Image.Image, size: tuple[int, int]) -> Image.Image: + """Banner = full-bleed BG (light blue) canvas with the FG slate on the right.""" + width, height = size + canvas = Image.new("RGBA", size, BG) + pad = max(2, round(height * 0.10)) + icon_x = width - slate_fg.width - pad + icon_y = (height - slate_fg.height) // 2 + canvas.paste(slate_fg, (icon_x, icon_y), slate_fg) + return canvas + + +def _compose_dialog(slate_bg: Image.Image, size: tuple[int, int]) -> Image.Image: + """Dialog = white canvas with FG strip on the left holding a BG-tinted slate.""" + width, height = size + strip_w = round(width * DIALOG_STRIP_FRAC) + canvas = Image.new("RGBA", size, (255, 255, 255, 255)) + draw = ImageDraw.Draw(canvas) + draw.rectangle([(0, 0), (strip_w, height)], fill=FG) + icon_x = (strip_w - slate_bg.width) // 2 + icon_y = round(height * 0.20) + canvas.paste(slate_bg, (icon_x, icon_y), slate_bg) + return canvas + + +def render_installer_jpegs(inkscape: str, work_dir: Path) -> None: + """Render the per-scale baseline JPEGs that ship inside the MSI. + + Outputs `Generated Images/installer_{banner,logo}{,.scale-125,.scale-150,.scale-200}.jpg` + from the master SVG. These are gitignored - pre_release.py --release rebuilds + them before each MSI build, so they always match the current logo without + being re-committed every time. + """ + GENERATED_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + # Render the slate at the exact target size each iteration - sharper than + # rendering once big and downsampling, and avoids Pillow's resize stub mismatch. + for scale, suffix in SCALES: + bw, bh = round(BANNER_BASE[0] * scale), round(BANNER_BASE[1] * scale) + dw, dh = round(DIALOG_BASE[0] * scale), round(DIALOG_BASE[1] * scale) + + # Banner icon sized off height (the limiting dim - banner is wide & short). + # Strip is wider than the icon, so the icon centers within it. + banner_icon_side = round(bh * BANNER_ICON_FRAC) + slate_fg = _render_slate(inkscape, work_dir, banner_icon_side, inverted=False) + + dialog_strip_w = round(dw * DIALOG_STRIP_FRAC) + dialog_icon_side = round(dialog_strip_w * DIALOG_ICON_FRAC) + slate_bg = _render_slate(inkscape, work_dir, dialog_icon_side, inverted=True) + + banner_path = GENERATED_IMAGES_DIR / f"installer_banner{suffix}.jpg" + dialog_path = GENERATED_IMAGES_DIR / f"installer_logo{suffix}.jpg" + print(f" {banner_path.relative_to(REPO_DIR)} ({bw}x{bh})") + _save_baseline_jpeg(_compose_banner(slate_fg, (bw, bh)), banner_path) + print(f" {dialog_path.relative_to(REPO_DIR)} ({dw}x{dh})") + _save_baseline_jpeg(_compose_dialog(slate_bg, (dw, dh)), dialog_path) + + +def render_installer_static(inkscape: str, work_dir: Path) -> None: + """Render the stable, committed installer assets - only re-run when the logo changes. + + Outputs: + - psd_square_small.ico (copy of pyscenedetect.ico) + - installer_banner.png, installer_logo.png (top-level audit masters) + - installer_banner.svg, installer_logo.svg (top-level + Generated Images/, master SVG copies) + """ + GENERATED_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + + top_banner = INSTALLER_DIR / "installer_banner.png" + top_dialog = INSTALLER_DIR / "installer_logo.png" + tbw, tbh = TOP_LEVEL_BANNER_PNG_SIZE + tdw, tdh = TOP_LEVEL_DIALOG_PNG_SIZE + top_slate_fg = _render_slate(inkscape, work_dir, round(tbh * BANNER_ICON_FRAC), inverted=False) + top_dialog_strip = round(tdw * DIALOG_STRIP_FRAC) + top_slate_bg = _render_slate( + inkscape, work_dir, round(top_dialog_strip * DIALOG_ICON_FRAC), inverted=True + ) + print(f" {top_banner.relative_to(REPO_DIR)} ({tbw}x{tbh})") + _compose_banner(top_slate_fg, TOP_LEVEL_BANNER_PNG_SIZE).save(top_banner, "PNG") + print(f" {top_dialog.relative_to(REPO_DIR)} ({tdw}x{tdh})") + _compose_dialog(top_slate_bg, TOP_LEVEL_DIALOG_PNG_SIZE).save(top_dialog, "PNG") + + # SVG references: drop a copy of the master logo+bg SVG at every spot the + # repo previously kept a reference rendering. These aren't read at MSI build + # time (the JPGs are what ship); they exist as audit artifacts. + for dest in ( + INSTALLER_DIR / "installer_banner.svg", + INSTALLER_DIR / "installer_logo.svg", + GENERATED_IMAGES_DIR / "installer_banner.svg", + GENERATED_IMAGES_DIR / "installer_logo.svg", + ): + shutil.copy2(LOGO_BG_SVG, dest) + print(f" {dest.relative_to(REPO_DIR)} <- {LOGO_BG_SVG.name}") + + # ARP product icon: reuse pyscenedetect.ico under the filename the .aip + # references (line 17: ARPPRODUCTICON psd_square_small). + shutil.copy2(ICO_PATH, ARP_ICO_PATH) + print(f" {ARP_ICO_PATH.relative_to(REPO_DIR)} <- {ICO_PATH.name}") + + +def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: + """Render the SVG at all icon sizes, applying sharpening where configured.""" + images = [] + for size in RASTER_SIZES: + png_path = work_dir / f"icon_{size}.png" + if size == 16: + print(f" Using hand-crafted {size}x{size} icon...") + img = make_icon_16() + img.save(png_path) + else: + svg_path = SVG_FOR_SIZE[size] + print(f" Rendering {size}x{size} using {svg_path.name}...") + render_svg(inkscape, svg_path, png_path, size, size) + img = Image.open(png_path).copy() + if size in SHARPEN_AMOUNT: + img = img.filter( + ImageFilter.UnsharpMask( + radius=SHARPEN_RADIUS, percent=SHARPEN_AMOUNT[size], threshold=0 + ) + ) + print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + img.save(png_path) + images.append(img) + return images + + +def main(): + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "persist_dir", + nargs="?", + type=Path, + help="Optional directory to persist intermediate PNGs (default: tempdir).", + ) + parser.add_argument( + "--installer-jpegs", + action="store_true", + help=( + "Only regenerate the per-build installer JPGs (Generated Images/*.jpg). " + "Used by pre_release.py --release before the MSI build." + ), + ) + args = parser.parse_args() + + persist_dir = args.persist_dir + if persist_dir: + persist_dir.mkdir(parents=True, exist_ok=True) + print(f"Persisting PNGs to: {persist_dir}") + + inkscape = find_inkscape() + print(f"Using Inkscape: {inkscape}") + print(f"Logo directory: {LOGO_DIR}") + + ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() + with ctx as work: + if args.installer_jpegs: + print("Rendering installer JPGs...") + render_installer_jpegs(inkscape, Path(work)) + return + + images = render_all_sizes(inkscape, Path(work)) + images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) + + print(f"Output ICO: {ICO_PATH}") + print("Copying favicons...") + for dest in FAVICON_OUTPUTS: + shutil.copy2(ICO_PATH, dest) + print(f" {dest.relative_to(REPO_DIR)}") + render_logos(inkscape) + print("Rendering installer branding (static assets)...") + render_installer_static(inkscape, Path(work)) + print("Rendering installer JPGs...") + render_installer_jpegs(inkscape, Path(work)) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_goldens.py b/scripts/generate_goldens.py new file mode 100644 index 00000000..26bdf107 --- /dev/null +++ b/scripts/generate_goldens.py @@ -0,0 +1,87 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Generates golden cut-lists in JSON format for the release test suite.""" + +import argparse +import json +import os + +from scenedetect import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + SceneManager, + ThresholdDetector, + open_video, +) + +VIDEOS = [ + "tests/resources/testvideo.mp4", + "tests/resources/goldeneye.mp4", + "tests/resources/goldeneye-vfr.mp4", + "tests/resources/goldeneye-vfr-drop3.mp4", + "tests/resources/fades.mp4", + "tests/resources/counter.mp4", +] + +# (DetectorClass, params, name_suffix) +DETECTORS = [ + (ContentDetector, {}, "default"), + (ContentDetector, {"threshold": 30.0}, "t30"), + (AdaptiveDetector, {}, "default"), + (AdaptiveDetector, {"adaptive_threshold": 5.0}, "t5"), + (ThresholdDetector, {}, "default"), + (HistogramDetector, {}, "default"), + (HashDetector, {}, "default"), +] + + +def generate_golden(video_path: str, detector_class, params: dict) -> list[int]: + video = open_video(video_path, backend="pyav") + scene_manager = SceneManager() + scene_manager.add_detector(detector_class(**params)) + scene_manager.detect_scenes(video) + scene_list = scene_manager.get_scene_list() + # Return start frame of each scene except the first one (which is 0) + return [scene[0].get_frames() for scene in scene_list[1:]] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", default="tests/resources/goldens") + args = parser.parse_args() + + if not os.path.exists(args.output_dir): + os.makedirs(args.output_dir) + + for video_path in VIDEOS: + if not os.path.exists(video_path): + print(f"Skipping {video_path}, not found.") + continue + + video_name = os.path.basename(video_path) + for detector_class, params, suffix in DETECTORS: + detector_name = detector_class.__name__ + print(f"Generating golden for {video_name} with {detector_name} ({suffix})...") + try: + cuts = generate_golden(video_path, detector_class, params) + output_filename = f"{video_name}.{detector_name}.{suffix}.json" + output_path = os.path.join(args.output_dir, output_filename) + with open(output_path, "w") as f: + json.dump({"cuts": cuts}, f) + except Exception as e: + print(f"Failed to generate golden for {video_name} with {detector_name}: {e}") + + +if __name__ == "__main__": + main() diff --git a/dist/pre_release.py b/scripts/pre_release.py similarity index 54% rename from dist/pre_release.py rename to scripts/pre_release.py index 8bec0f1f..67f58b98 100644 --- a/dist/pre_release.py +++ b/scripts/pre_release.py @@ -5,51 +5,78 @@ # [ 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. # -# Pre-release script to run before invoking `pyinstaller`: -# -# python dist/pre_release.py -# pyinstaller dist/scenedetect.spec -# -import os +""" +Pre-release script to run before invoking `pyinstaller` when building the Windows distribution: +```bash +python scripts/pre_release.py +pyinstaller packaging/windows/scenedetect.spec +``` +""" + import sys -sys.path.append(os.path.abspath(".")) +import tempfile +from pathlib import Path -import scenedetect +SCRIPTS_DIR = Path(__file__).resolve().parent +REPO_DIR = SCRIPTS_DIR.parent +sys.path.insert(0, str(REPO_DIR)) +sys.path.insert(0, str(SCRIPTS_DIR)) +from generate_assets import find_inkscape, render_installer_jpegs # noqa: E402 +from update_installer import msi_version # noqa: E402 + +import scenedetect # noqa: E402 + +PACKAGING_DIR = REPO_DIR / "packaging" +WINDOWS_DIR = PACKAGING_DIR / "windows" +INSTALLER_AIP = WINDOWS_DIR / "installer" / "PySceneDetect.aip" +VERSION_INFO = WINDOWS_DIR / ".version_info" VERSION = scenedetect.__version__ -run_version_check = ("--release" in sys.argv) +run_version_check = "--release" in sys.argv if run_version_check: - installer_aip = '' - with open("dist/installer/PySceneDetect.aip", "r") as f: - installer_aip = f.read() - aip_version = f"" - assert aip_version in installer_aip, f"Installer project version does not match {VERSION}." + installer_aip = INSTALLER_AIP.read_text() + # The .aip stores the numeric MSI form (e.g. "0.7.0"), not the Python __version__ + # (which may be "0.7-dev0", "0.7", "0.7.1", ...). Normalize through the same + # function update_installer.py uses to write the .aip so the comparison is correct. + expected = msi_version(VERSION) + aip_row = f'' + assert aip_row in installer_aip, ( + f"Installer ProductVersion does not match normalized {VERSION!r} ({expected!r}). " + f"Run `python scripts/update_installer.py` to refresh the .aip." + ) + + # Refresh installer JPGs from the master SVG. + print("Regenerating installer JPGs...") + inkscape = find_inkscape() + with tempfile.TemporaryDirectory() as work: + render_installer_jpegs(inkscape, Path(work)) -with open("dist/.version_info", "wb") as f: +with VERSION_INFO.open("wb") as f: v = VERSION.split(".") assert 2 <= len(v) <= 4, f"Unrecognized version format: {VERSION}" while len(v) < 4: - v.append("0") + v.append("0") (maj, min, pat, bld) = v[0], v[1], v[2], v[3] # If either major or minor have suffixes, assume it's a dev/beta build and set # the final component to 999. if not min.isdigit(): - assert "-" in min - min = min[:min.find("-")] - bld = 999 + assert "-" in min + min = min[: min.find("-")] + bld = 999 if not pat.isdigit(): - assert "-" in pat - pat = pat[:pat.find("-")] - bld = 999 - f.write(f"""# UTF-8 + assert "-" in pat + pat = pat[: pat.find("-")] + bld = 999 + f.write( + f"""# UTF-8 # # For more details about fixed file info 'ffi' see: # http://msdn.microsoft.com/en-us/library/ms646997.aspx @@ -92,4 +119,5 @@ VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) ] ) -""".encode()) +""".encode() + ) diff --git a/scripts/stage_windows_dist.py b/scripts/stage_windows_dist.py new file mode 100644 index 00000000..96839326 --- /dev/null +++ b/scripts/stage_windows_dist.py @@ -0,0 +1,182 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Stages Windows distribution assets into dist/scenedetect/. + +Sequence in a release to generate the installer: + +```bash + python scripts/pre_release.py + pyinstaller packaging/windows/scenedetect.spec + python scripts/stage_windows_dist.py --ffmpeg-dir + python scripts/update_installer.py --sync-files + AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip +``` + +After SignPath returns the signed bundle, run `scripts/finalize_windows_dist.py` +locally to swap in the signed exe, repack the portable .zip, and emit the +SHA256 manifests. + +This script assumes it is run on a Windows machine. +""" + +# TODO: This should be called from the Github Actions workflow as well, right now it's only +# done from the appveyor one. When that's done it should be merged with update_installer.py +# into a combined "prepare_windows_dist.py". + +import argparse +import shutil +import subprocess +import sys +import zipfile + +if sys.platform != "win32": + print("Error: stage_windows_dist.py must be run on Windows.", file=sys.stderr) + sys.exit(1) +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _release_common import display_version, find_7zip # noqa: E402 + +import scenedetect # noqa: E402 + +DIST_DIR = REPO_DIR / "dist" +DIST_TREE = DIST_DIR / "scenedetect" +PACKAGING_WIN = REPO_DIR / "packaging" / "windows" +DOCS_DIR = REPO_DIR / "docs" +THIRDPARTY_LICENSES = REPO_DIR / "scenedetect" / "_thirdparty" + + +def _rel(p: Path) -> str: + # Display paths relative to the repo when possible, else fall back to the + # absolute path (e.g. --ffmpeg-dir pointing outside the repo on CI). + try: + return str(p.relative_to(REPO_DIR)) + except ValueError: + return str(p) + + +def copy_file(src: Path, dst: Path) -> None: + if not src.exists(): + print(f"WARNING: {src} missing - skipping {dst.name}") + return + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + print(f" {_rel(src)} -> {_rel(dst)}") + + +def stage_ffmpeg(ffmpeg_dir: Path | None) -> None: + thirdparty = DIST_TREE / "thirdparty" + thirdparty.mkdir(parents=True, exist_ok=True) + if ffmpeg_dir is not None: + print(f"Copying ffmpeg from {ffmpeg_dir}") + copy_file(ffmpeg_dir / "ffmpeg.exe", DIST_TREE / "ffmpeg.exe") + copy_file(ffmpeg_dir / "LICENSE", thirdparty / "LICENSE-FFMPEG") + return + archive = PACKAGING_WIN / "thirdparty.7z" + if not archive.exists(): + sys.exit(f"No --ffmpeg-dir given and {archive} missing.") + sevenz = find_7zip() + staging = DIST_TREE / "_thirdparty_extract" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + print(f"Extracting {archive.name} (bundled fallback)...") + subprocess.run( + [str(sevenz), "x", str(archive), f"-o{staging}", "windows/ffmpeg.exe", "-y"], + check=True, + capture_output=True, + ) + src = staging / "windows" / "ffmpeg.exe" + if src.exists(): + shutil.move(str(src), str(DIST_TREE / "ffmpeg.exe")) + print(" ffmpeg.exe -> dist/scenedetect/ffmpeg.exe") + shutil.rmtree(staging) + # The bundled archive predates LICENSE-FFMPEG; emit a stub pointing at upstream. + stub = thirdparty / "LICENSE-FFMPEG" + stub.write_text( + "FFmpeg is licensed under the LGPL/GPL. See https://ffmpeg.org/legal.html " + "for the canonical license text matching the bundled binary.\n", + encoding="utf-8", + ) + print(f" (stub) -> {stub.relative_to(REPO_DIR)}") + + +def build_docs() -> None: + if not (DOCS_DIR / "Makefile").exists(): + print("WARNING: docs/Makefile missing - skipping docs build") + return + print("Building Sphinx docs (singlehtml)...") + target = DIST_TREE / "docs" + if target.exists(): + shutil.rmtree(target) + subprocess.run( + [sys.executable, "-m", "sphinx", "-b", "singlehtml", str(DOCS_DIR), str(target)], + check=True, + ) + print(" docs -> dist/scenedetect/docs/") + + +def stage_thirdparty_licenses() -> None: + target = DIST_TREE / "thirdparty" + target.mkdir(parents=True, exist_ok=True) + print("Staging third-party licenses...") + for src in sorted(THIRDPARTY_LICENSES.glob("LICENSE-*")): + copy_file(src, target / src.name) + copy_file(PACKAGING_WIN / "LICENSE-PYTHON", target / "LICENSE-PYTHON") + + +def make_portable_zip(version: str) -> None: + zip_path = DIST_DIR / f"PySceneDetect-{version}-win64.zip" + manifest_path = DIST_DIR / f"PySceneDetect-{version}-win64.manifest.txt" + if zip_path.exists(): + zip_path.unlink() + print(f"Creating {zip_path.relative_to(REPO_DIR)}...") + files = sorted(p for p in DIST_TREE.rglob("*") if p.is_file()) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for path in files: + zf.write(path, path.relative_to(DIST_TREE)) + print(f" {zip_path.stat().st_size / (1024 * 1024):.1f} MB") + manifest_path.write_text( + "\n".join(str(p.relative_to(DIST_TREE)) for p in files) + "\n", + encoding="utf-8", + ) + print(f" manifest -> {manifest_path.relative_to(REPO_DIR)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "--ffmpeg-dir", + type=Path, + help="Directory containing ffmpeg.exe and its LICENSE. " + "If omitted, ffmpeg is extracted from packaging/windows/thirdparty.7z.", + ) + args = parser.parse_args() + + if not DIST_TREE.exists(): + sys.exit(f"{DIST_TREE} not found. Run pyinstaller first.") + + print(f"Staging into {DIST_TREE.relative_to(REPO_DIR)}") + stage_ffmpeg(args.ffmpeg_dir) + print("Copying root files...") + copy_file(REPO_DIR / "LICENSE", DIST_TREE / "LICENSE") + copy_file(PACKAGING_WIN / "README.txt", DIST_TREE / "README.txt") + stage_thirdparty_licenses() + build_docs() + make_portable_zip(display_version(scenedetect.__version__)) + + +if __name__ == "__main__": + main() diff --git a/scripts/update_installer.py b/scripts/update_installer.py new file mode 100644 index 00000000..34b23cb0 --- /dev/null +++ b/scripts/update_installer.py @@ -0,0 +1,146 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Update the AdvancedInstaller .aip project for a release. + +Usage: + python scripts/update_installer.py # version bump only + python scripts/update_installer.py --sync-files # bump + re-sync APPDIR + python scripts/update_installer.py --sync-only # re-sync APPDIR only (CI) + python scripts/update_installer.py --sync-only --dev # CI dev build (renames MSI) + python scripts/update_installer.py --version 0.7.0 # explicit version override +""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _release_common import display_version, msi_version # noqa: E402 + +import scenedetect # noqa: E402 + +INSTALLER_AIP = REPO_DIR / "packaging" / "windows" / "installer" / "PySceneDetect.aip" +DIST_TREE = REPO_DIR / "dist" / "scenedetect" + + +def find_advinst() -> Path: + if env := os.environ.get("ADVINST"): + path = Path(env) + if not path.exists(): + sys.exit(f"ADVINST={env} does not exist.") + return path + candidates = sorted( + Path(r"C:\Program Files (x86)\Caphyon").glob( + "Advanced Installer*/bin/x86/AdvancedInstaller.com" + ) + ) + if not candidates: + sys.exit( + "AdvancedInstaller.com not found under C:\\Program Files (x86)\\Caphyon. " + "Set the ADVINST environment variable to its full path." + ) + return candidates[-1] + + +def run(advinst: Path, *edit_args: str, check: bool = True) -> int: + cmd = [str(advinst), "/edit", str(INSTALLER_AIP), *edit_args] + print(">", " ".join(cmd)) + return subprocess.run(cmd, check=check).returncode + + +def resync_appdir(advinst: Path) -> None: + if not DIST_TREE.exists(): + sys.exit( + f"{DIST_TREE} not found. Run `pyinstaller packaging/windows/scenedetect.spec` first." + ) + # /ResetSync errors out if APPDIR isn't already a synced folder + # (true on the first run); /NewSync will fail if it IS synced. So + # try the reset but tolerate failure, then sync. + run(advinst, "/ResetSync", "APPDIR", check=False) + run(advinst, "/NewSync", "APPDIR", str(DIST_TREE)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--sync-files", + action="store_true", + help="Bump version/GUIDs AND re-sync APPDIR from dist/scenedetect/.", + ) + mode.add_argument( + "--sync-only", + action="store_true", + help="Re-sync APPDIR only; leave version/GUID fields untouched (CI use).", + ) + parser.add_argument( + "--dev", + action="store_true", + help=( + "Rename the MSI to PySceneDetect-{ver}-dev-win64.msi so dev-build artifacts " + "are distinguishable from release artifacts. Only valid with --sync-only." + ), + ) + parser.add_argument( + "--version", + dest="version_override", + help="MSI version override (default: derived from scenedetect.__version__).", + ) + args = parser.parse_args() + + if args.dev and not args.sync_only: + sys.exit("--dev is only valid in combination with --sync-only.") + + advinst = find_advinst() + print(f"Using {advinst}") + + if args.sync_only: + print(f"Re-syncing APPDIR in {INSTALLER_AIP.name}") + resync_appdir(advinst) + if args.dev: + raw_version = args.version_override or scenedetect.__version__ + file_version = display_version(raw_version) + dev_name = f"PySceneDetect-{file_version}-dev-win64.msi" + print(f"Renaming MSI package to {dev_name} (dev build)") + run(advinst, "/SetPackageName", dev_name, "-buildname", "DefaultBuild") + return + + raw_version = args.version_override or scenedetect.__version__ + product_version = msi_version(raw_version) + file_version = display_version(raw_version) + if not all(p.isdigit() for p in product_version.split(".") if p): + sys.exit(f"Cannot derive numeric MSI version from {raw_version!r}") + if product_version != raw_version: + print(f"Normalized {raw_version!r} -> {product_version!r} for AdvancedInstaller") + print(f"Bumping {INSTALLER_AIP.name} to {product_version} (filename: {file_version})") + + run(advinst, "/SetVersion", product_version) + run(advinst, "/SetProductCode", "-langid", "1033") + run( + advinst, + "/SetPackageName", + f"PySceneDetect-{file_version}-win64.msi", + "-buildname", + "DefaultBuild", + ) + + if args.sync_files: + resync_appdir(advinst) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_release.py b/scripts/validate_release.py new file mode 100644 index 00000000..b2ccad3b --- /dev/null +++ b/scripts/validate_release.py @@ -0,0 +1,440 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Validate finalized Windows release artifacts. + +Runs against the staging directory produced by `scripts/finalize_windows_dist.py` +(default `dist/signed/`) and verifies the artifacts that go up to a GitHub +release. Catches regressions that only manifest in the post-build artifact, not +in unit tests: + + 1. Filename presence and `-win64` suffix consistency + 2. SHA256 of `.zip` and `.msi` matches `SHA256SUMS` and `manifest.json`, + and per-file hashes inside the portable .zip match the manifest + 3. Authenticode signatures on the `.msi` and the `scenedetect.exe` inside + the portable `.zip` + 4. MSI / portable-zip parity: every file in the portable .zip exists in + the MSI (matched by SHA256, name-agnostic to tolerate MSI mangling) + 5. Frozen `.exe` smoke tests: + - `scenedetect.exe version` prints the expected version + - No required dependency is reported as "Not Installed" + - A short `detect-content` invocation succeeds (skipped if the test + video resource is absent) + - Default error path produces a clean error, not a Python traceback + +Re-run standalone after fixing any failure: + python scripts/validate_release.py [--staging-dir DIR] +""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _release_common import ( # noqa: E402 + display_version, + find_7zip, + hash_zip_contents, + sha256_file, + verify_authenticode, +) + +import scenedetect # noqa: E402 + +VERSION = display_version(scenedetect.__version__) + +# Mirrors `third_party_packages` in `scenedetect/platform.py:get_system_version_info()`. +# Keep these two lists in sync: any package added there should be classified here as +# either REQUIRED (must report a version in the frozen .exe) or OPTIONAL (one of a +# mutually-exclusive pair that legitimately reports "Not Installed" in the bundle). +REQUIRED_PACKAGES = ( + "scenedetect", + "av", + "click", + "imageio", + "imageio-ffmpeg", + "moviepy", + "numpy", + "platformdirs", + "tqdm", +) +# Exactly one of these must report a version. The frozen Windows build ships +# `opencv-python-headless` only, so `opencv-python` legitimately reports "Not Installed". +OPENCV_VARIANTS = ("opencv-python", "opencv-python-headless") + +NOT_INSTALLED = "Not Installed" + + +def fail(message: str) -> None: + """Print FAIL marker and bubble out as a SystemExit so finalize stops.""" + sys.exit(f"VALIDATION FAILED: {message}") + + +def section(name: str) -> None: + print() + print(f"[{name}]") + + +def check_filenames(staging: Path) -> tuple[Path, Path, Path]: + """Step 1: required artifacts present, no stray inconsistent suffixes.""" + section("Filenames") + portable_zip = staging / f"PySceneDetect-{VERSION}-win64.zip" + msi = staging / f"PySceneDetect-{VERSION}-win64.msi" + manifest = staging / f"PySceneDetect-{VERSION}-win64.manifest.json" + sums = staging / "SHA256SUMS" + + for required in (portable_zip, msi, manifest, sums): + if not required.is_file(): + fail(f"missing required artifact: {required.name}") + print(f" found {required.name}") + + # Reject filename patterns that proved problematic during v0.7 release smoke testing. + # Both bugs were caused by inconsistent suffixes between portable .zip and .msi. + stray_suffixed = list(staging.glob("PySceneDetect-*-portable.zip")) + if stray_suffixed: + fail( + "found stale '-portable' artifacts (inconsistency caught in commit 550a5ad): " + + ", ".join(p.name for p in stray_suffixed) + ) + for zip_path in staging.glob("PySceneDetect-*.zip"): + # Allow the canonical name + the .unsigned.zip backup written by finalize. + if zip_path == portable_zip or zip_path.name.endswith(".unsigned.zip"): + continue + fail( + f"unexpected portable .zip without '-win64' suffix: {zip_path.name} " + "(suffix inconsistency caught in commit 9421592)" + ) + for msi_path in staging.glob("PySceneDetect-*.msi"): + if msi_path == msi: + continue + fail( + f"unexpected stray MSI: {msi_path.name} " + "(only one canonical PySceneDetect-X.Y.Z-win64.msi expected)" + ) + + return portable_zip, msi, manifest + + +def check_hashes(staging: Path, portable_zip: Path, msi: Path, manifest_path: Path) -> dict: + """Step 2: SHA256 of .zip / .msi matches SHA256SUMS and manifest.json, + and per-file hashes inside the portable .zip match the manifest.""" + section("Hashes") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + if manifest.get("version") != VERSION: + fail(f"manifest version {manifest.get('version')!r} != expected {VERSION!r}") + + portable_actual = sha256_file(portable_zip) + msi_actual = sha256_file(msi) + print(f" {portable_zip.name}: {portable_actual}") + print(f" {msi.name}: {msi_actual}") + + if manifest["bundles"]["portable_zip"]["sha256"] != portable_actual: + fail(f"manifest portable_zip sha256 mismatch ({portable_zip.name})") + if manifest["bundles"]["msi"]["sha256"] != msi_actual: + fail(f"manifest msi sha256 mismatch ({msi.name})") + + sums_text = (staging / "SHA256SUMS").read_text(encoding="utf-8") + expected_lines = { + f"{msi_actual} {msi.name}", + f"{portable_actual} {portable_zip.name}", + } + actual_lines = {line.strip() for line in sums_text.splitlines() if line.strip()} + if expected_lines != actual_lines: + fail( + "SHA256SUMS does not match recomputed digests.\n" + f" expected: {sorted(expected_lines)}\n" + f" actual: {sorted(actual_lines)}" + ) + print(" SHA256SUMS matches") + + print(f" re-hashing {portable_zip.name} contents...") + actual_contents = hash_zip_contents(portable_zip) + expected_contents = manifest["bundles"]["portable_zip"]["contents"] + actual_by_path = {entry["path"]: entry for entry in actual_contents} + expected_by_path = {entry["path"]: entry for entry in expected_contents} + if actual_by_path.keys() != expected_by_path.keys(): + only_actual = sorted(actual_by_path.keys() - expected_by_path.keys()) + only_manifest = sorted(expected_by_path.keys() - actual_by_path.keys()) + fail( + "manifest contents file list does not match portable .zip:\n" + f" only in zip: {only_actual}\n" + f" only in manifest: {only_manifest}" + ) + for path, expected in expected_by_path.items(): + actual = actual_by_path[path] + if actual["sha256"] != expected["sha256"] or actual["size"] != expected["size"]: + fail(f"manifest content mismatch for {path}: {expected} vs {actual}") + print(f" manifest matches all {len(actual_by_path)} entries inside portable .zip") + return manifest + + +def check_signatures(portable_zip: Path, msi: Path) -> None: + """Step 3: Authenticode on .msi and on scenedetect.exe inside the portable .zip.""" + section("Signatures") + print(f" verifying {msi.name}") + verify_authenticode(msi) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with zipfile.ZipFile(portable_zip) as zf: + try: + zf.extract("scenedetect.exe", tmp_path) + except KeyError: + fail(f"scenedetect.exe not found at root of {portable_zip.name}") + exe_path = tmp_path / "scenedetect.exe" + print(f" verifying scenedetect.exe inside {portable_zip.name}") + verify_authenticode(exe_path) + + +def _hashes_in_dir(root: Path) -> set[str]: + return {sha256_file(p) for p in root.rglob("*") if p.is_file()} + + +def check_msi_zip_parity(portable_zip: Path, msi: Path, sevenz: Path) -> None: + """Step 4: every file in the portable .zip should exist (by content) + inside the MSI. We compare SHA256 sets to be name-agnostic - 7-Zip's MSI + extraction can mangle filenames, so name-by-name diffs are unreliable, but + content hashes are exact.""" + section("MSI / portable-zip parity") + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + msi_dir = tmp_path / "msi" + zip_dir = tmp_path / "zip" + msi_dir.mkdir() + zip_dir.mkdir() + + # Extract MSI (which may produce inner .cab archives that themselves need + # extracting to recover the actual installed file tree). + print(f" extracting {msi.name} with 7-Zip...") + subprocess.run( + [str(sevenz), "x", str(msi), f"-o{msi_dir}", "-y"], + check=True, + capture_output=True, + ) + cabs = list(msi_dir.rglob("*.cab")) + for cab in cabs: + print(f" expanding inner archive: {cab.name}") + subprocess.run( + [str(sevenz), "x", str(cab), f"-o{cab.parent}", "-y"], + check=True, + capture_output=True, + ) + cab.unlink() + + print(f" extracting {portable_zip.name}...") + with zipfile.ZipFile(portable_zip) as zf: + zf.extractall(zip_dir) + + msi_hashes = _hashes_in_dir(msi_dir) + zip_hashes = _hashes_in_dir(zip_dir) + missing_from_msi = zip_hashes - msi_hashes + if missing_from_msi: + # Re-walk the portable zip to attach names to the missing hashes. + zip_by_hash = {} + for p in zip_dir.rglob("*"): + if p.is_file(): + zip_by_hash.setdefault(sha256_file(p), p.relative_to(zip_dir).as_posix()) + named = sorted(zip_by_hash.get(h, h) for h in missing_from_msi) + fail( + f"{len(missing_from_msi)} file(s) present in portable .zip but not in MSI:\n" + + "\n".join(f" {n}" for n in named[:25]) + + (f"\n ... ({len(named) - 25} more)" if len(named) > 25 else "") + ) + print( + f" all {len(zip_hashes)} files in portable .zip are present in MSI " + f"({len(msi_hashes)} files in MSI total)" + ) + + +def _parse_packages_section(version_output: str) -> dict[str, str]: + """Parse the 'Packages' section of `scenedetect version` output.""" + packages: dict[str, str] = {} + in_section = False + for raw in version_output.splitlines(): + line = raw.rstrip() + if not in_section: + if line.strip() == "Packages": + in_section = True + continue + # Section ends on blank line, separator, or next header. + if not line.strip() or line.strip().startswith("---") or line.strip() == "Tools": + if line.strip() == "Tools": + break + continue + # Format: "". Split on first run of >=2 spaces. + parts = line.split(None, 1) + if len(parts) != 2: + continue + name, value = parts[0].strip(), parts[1].strip() + packages[name] = value + return packages + + +def check_frozen_exe(portable_zip: Path) -> None: + """Step 5: extract portable .zip, run scenedetect.exe, verify version, + package detection, smoke detect, and clean error path.""" + section("Frozen .exe smoke tests") + if sys.platform != "win32": + print(" (skipping .exe smoke tests on non-Windows)") + return + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with zipfile.ZipFile(portable_zip) as zf: + zf.extractall(tmp_path) + exe = tmp_path / "scenedetect.exe" + if not exe.is_file(): + fail(f"scenedetect.exe not found at root of {portable_zip.name}") + + # 5a. `version` prints VERSION and well-formed package table. + result = subprocess.run( + [str(exe), "version"], + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + ) + if result.returncode != 0: + fail(f"`scenedetect.exe version` exited {result.returncode}\n{result.stderr}") + packages = _parse_packages_section(result.stdout) + scenedetect_reported = packages.get("scenedetect", "") + # Normalize both sides through display_version() so a raw __version__ + # like "0.7-dev0" matches the artifact-name VERSION of "0.7". + if display_version(scenedetect_reported) != VERSION: + fail( + f"`scenedetect.exe version` reports scenedetect=={scenedetect_reported!r}, " + f"expected {VERSION!r} (raw __version__ normalized)" + ) + print(f" scenedetect=={scenedetect_reported}") + + # 5b. No required dependency reports "Not Installed" - the bug fixed in c6a4145. + broken = [name for name in REQUIRED_PACKAGES if packages.get(name) == NOT_INSTALLED] + if broken: + fail( + "frozen .exe reports required packages as 'Not Installed' " + "(commit c6a4145 regression):\n " + ", ".join(broken) + ) + opencv_present = [ + v for v in OPENCV_VARIANTS if packages.get(v, NOT_INSTALLED) != NOT_INSTALLED + ] + if not opencv_present: + fail( + "neither opencv-python nor opencv-python-headless reported a version " + "(at least one must be present in the bundle)" + ) + print(f" opencv variant present: {opencv_present[0]}=={packages[opencv_present[0]]}") + for name in REQUIRED_PACKAGES: + print(f" {name}=={packages[name]}") + + # 5c. Functional smoke: short detect-content run on the test video, if available. + test_video = REPO_DIR / "tests" / "resources" / "testvideo.mp4" + if test_video.is_file(): + out_dir = tmp_path / "smoke_output" + out_dir.mkdir() + print(f" running detect-content on {test_video.name}...") + result = subprocess.run( + [ + str(exe), + "-i", + str(test_video), + "-o", + str(out_dir), + "detect-content", + "time", + "-e", + "2s", + "list-scenes", + ], + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + ) + if result.returncode != 0: + fail( + f"detect-content smoke run exited {result.returncode}\n" + f" stdout: {result.stdout}\n stderr: {result.stderr}" + ) + outputs = list(out_dir.iterdir()) + if not outputs: + fail("detect-content smoke run produced no output files") + print(f" detect-content OK ({len(outputs)} output file(s))") + else: + print( + f" (skipping detect-content smoke; {test_video.relative_to(REPO_DIR)} " + "not present locally)" + ) + + # 5d. Clean error path: SCENEDETECT_DEBUG unset must produce a logger-formatted + # error, not a Python traceback. Catches the __debug__ regression in c6a4145 + # (PyInstaller's -O bytecode makes `if __debug__:` always-False, so the wrong + # branch fired and tracebacks leaked to end users). + nonexistent = tmp_path / "definitely-not-a-video.mp4" + clean_env = dict(os.environ) + clean_env.pop("SCENEDETECT_DEBUG", None) + result = subprocess.run( + [str(exe), "-i", str(nonexistent), "detect-content"], + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + env=clean_env, + ) + if result.returncode == 0: + fail("error path: scenedetect.exe exited 0 on a missing input file") + if "Traceback" in result.stderr or "Traceback" in result.stdout: + fail( + "error path: scenedetect.exe surfaced a Python traceback to the user " + "(commit c6a4145 __debug__ regression):\n" + f" stderr: {result.stderr.strip()[:500]}" + ) + print(" error path: clean exit (no traceback)") + + +def run_all_checks(staging: Path) -> None: + """Entrypoint shared with `finalize_windows_dist.py`. Raises SystemExit on failure.""" + if not staging.is_dir(): + fail(f"staging directory not found: {staging}") + print(f"Validating release artifacts in: {staging}") + print(f"Expected version: {VERSION}") + + portable_zip, msi, manifest_path = check_filenames(staging) + check_hashes(staging, portable_zip, msi, manifest_path) + check_signatures(portable_zip, msi) + sevenz = find_7zip() + check_msi_zip_parity(portable_zip, msi, sevenz) + check_frozen_exe(portable_zip) + + print() + print("All validation checks passed.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "--staging-dir", + type=Path, + default=REPO_DIR / "dist" / "signed", + help="Directory containing finalized artifacts (default: dist/signed/).", + ) + args = parser.parse_args() + run_all_checks(args.staging_dir.resolve()) + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c01def6e..00000000 --- a/setup.cfg +++ /dev/null @@ -1,69 +0,0 @@ - -[metadata] -name = scenedetect -version = attr: scenedetect.__version__ -license = BSD-3-Clause -author = Brandon Castellano -author_email = brandon248@gmail.com -description = Video scene cut/shot detection program and Python library. -long_description = file: dist/package-info.rst -long_description_content_type = text/x-rst -url = https://www.scenedetect.com -project_urls = - Homepage = https://www.scenedetect.com - Repository = https://github.com/Breakthrough/PySceneDetect/ - Documentation = https://www.scenedetect.com/docs/ - Bug Tracker = https://github.com/Breakthrough/PySceneDetect/issues/ -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Environment :: Console :: Curses - Intended Audience :: Developers - Intended Audience :: End Users/Desktop - Intended Audience :: System Administrators - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: 3.13 - Topic :: Multimedia :: Video - Topic :: Multimedia :: Video :: Conversion - Topic :: Multimedia :: Video :: Non-Linear Editor - Topic :: Utilities -keywords = video computer-vision analysis - -[options] -install_requires = - # click <8.3.0 is excluded as per https://scenedetect.com/issues/521. - click~=8.0,<8.3.0 - numpy - platformdirs - tqdm -packages = - scenedetect - scenedetect._cli - scenedetect._thirdparty - scenedetect.backends - scenedetect.detectors - scenedetect.output -python_requires = >=3.10 - -[options.extras_require] -opencv = opencv-python -opencv-headless = opencv-python-headless -pyav = av>=9.2 -moviepy = moviepy - -[options.entry_points] -console_scripts = - scenedetect = scenedetect.__main__:main - -[aliases] -test = pytest - -[tool:pytest] -addopts = --verbose -python_files = tests/*.py -filterwarnings = - ignore:TODO.*Update caller to handle VFR:UserWarning diff --git a/setup.py b/setup.py deleted file mode 100644 index ec281380..00000000 --- a/setup.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://www.scenedetect.com/docs/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# -"""PySceneDetect setup.py - DEPRECATED. - -Build using `python -m build` and installing the resulting .whl using `pip`. -""" - -import setuptools - -if __name__ == "__main__": - setuptools.setup(name="scenedetect") 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 25bf517e..dee805a4 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. # @@ -32,6 +32,12 @@ import pytest +# Surface unhandled exceptions and KeyboardInterrupt as raw tracebacks during tests so pytest +# (and any debugger) sees the original failure instead of the logger-formatted output the CLI +# uses for end users. Read by `scenedetect.platform.DEBUG_MODE`. `setdefault` lets a developer +# override (e.g. `SCENEDETECT_DEBUG=` to mimic end-user behavior in a specific test run). +os.environ.setdefault("SCENEDETECT_DEBUG", "1") + # # Helper Functions # @@ -45,14 +51,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 @@ -114,6 +119,17 @@ def test_vfr_video() -> str: return check_exists("tests/resources/goldeneye-vfr.mp4") +@pytest.fixture +def test_vfr_drop3_video() -> str: + """Synthetic VFR video created from goldeneye.mp4 by dropping every 3rd frame. + + Frame pattern: keeps frames where (n+1) % 3 != 0 (i.e. drops frames 2,5,8,...). + Resulting PTS durations alternate: 1001, 2002, 1001, 2002, ... (time_base=1/24000). + Nominal fps: 24000/1001. Average fps: ~16 fps. Duration: ~10s, 160 frames. + """ + return check_exists("tests/resources/goldeneye-vfr-drop3.mp4") + + @pytest.fixture def corrupt_video_file() -> str: """Video containing a corrupted frame causing a decode failure.""" @@ -136,3 +152,60 @@ def test_image_sequence() -> str: def test_fades_clip() -> str: """Clip containing fades in/out.""" return check_exists("tests/resources/fades.mp4") + + +@pytest.fixture +def delayed_start_video() -> str: + """Video with a nonzero stream start time (1.075s edit-list offset). Created from + fades.mp4 via: ffmpeg -itsoffset 1.075 -i fades.mp4 -t 2 -c:v copy -an delayed_start.mp4""" + return check_exists("tests/resources/delayed_start.mp4") + + +@pytest.fixture +def auto_close(): + """Registers VideoStreams (or anything closeable) for deterministic cleanup at test end. + + Usage: ``video = auto_close(open_video(path))``. Returns its argument unchanged. + Closing test-owned streams while the interpreter is healthy avoids ResourceWarnings + (unclosed PyAV containers / file handles) finalizing during interpreter shutdown, + where native teardown can crash the process exit code (windows-latest CI flake). + """ + from tests.helpers import close_video_stream + + streams = [] + + def _register(stream): + streams.append(stream) + return stream + + yield _register + for stream in streams: + close_video_stream(stream) + + +def pytest_unconfigure(config): + """Diagnostic for a windows-latest CI flake (silent exit 1 after a green run): + report any non-main threads still alive at session end. Leaked threads keep + VideoStreams alive into interpreter shutdown, where native teardown can crash + the process exit code. tqdm's global monitor singleton is expected and ignored.""" + import gc + import sys + import threading + + # Finalize any lingering test-owned objects (e.g. av containers kept alive by reference + # cycles) while the interpreter is still healthy, instead of at interpreter shutdown. + gc.collect() + + leftover = [ + t + for t in threading.enumerate() + if t is not threading.main_thread() and t.name != "tqdm_monitor" + ] + for thread in leftover: + frame = sys._current_frames().get(thread.ident) if thread.ident else None + location = f"{frame.f_code.co_filename}:{frame.f_lineno}" if frame else "unknown" + print( + f"WARNING: thread still alive at exit: {thread.name} " + f"(daemon={thread.daemon}) at {location}", + file=sys.stderr, + ) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..00d33add --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,76 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Shared test helpers.""" + +import contextlib +import typing as ty + +from click.testing import CliRunner + +from scenedetect._cli import scenedetect as _scenedetect_cli +from scenedetect._cli.context import CliContext +from scenedetect._cli.controller import run_scenedetect + + +def close_video_stream(stream: ty.Any) -> None: + """Deterministically release a VideoStream's native resources. + + `VideoStream` has no public close()/context-manager API, so tests release the + backend-specific handles directly. Closing while the interpreter is healthy avoids + ResourceWarnings (and native teardown work) at interpreter shutdown. Safe to call + multiple times; never raises. + """ + backend = getattr(stream, "BACKEND_NAME", None) + if backend == "pyav": + # Close the decode generator first to break its cycle with the container. `_io` is + # the file handle backing the container (opened by the stream when given a path). + for attr in ("_decoder", "_container", "_io"): + handle = getattr(stream, attr, None) + if handle is not None: + with contextlib.suppress(Exception): + handle.close() + elif backend == "opencv": + cap = getattr(stream, "_cap", None) + if cap is not None: + with contextlib.suppress(Exception): + cap.release() + elif backend == "moviepy": + reader = getattr(stream, "_reader", None) + if reader is not None: + with contextlib.suppress(Exception): + reader.close() + + +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``: + + 1. ``scenedetect.main(obj=context)`` - parse args and register callbacks on ``CliContext`` + 2. ``run_scenedetect(context)`` - execute detection and output commands + + Returns ``(exit_code, output_text)``. + """ + context = CliContext() + runner = CliRunner() + try: + result = runner.invoke( + _scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions + ) + if result.exit_code == 0: + run_scenedetect(context) + return result.exit_code, result.output + finally: + # The CLI opens a VideoStream on `context` and has no teardown path; close it here so + # its native handles are released deterministically instead of at interpreter shutdown. + if context.video_stream is not None: + close_video_stream(context.video_stream) diff --git a/tests/release/__init__.py b/tests/release/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/release/conftest.py b/tests/release/conftest.py new file mode 100644 index 00000000..e0aac3a4 --- /dev/null +++ b/tests/release/conftest.py @@ -0,0 +1,81 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Fixtures for the release test suite.""" + +import os + +import pytest + +from .synthetic import ( + generate_synthetic_matrix_video, + generate_vfr_bframes, + generate_vfr_pts_gap, + generate_vfr_swing, +) + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.fixture(autouse=True) +def no_logs_gte_error(): + # Override the strict autouse guard from tests/conftest.py: release tests + # exercise known-pathological inputs that legitimately emit ERROR logs. + yield + + +@pytest.fixture +def vfr_swing_video(tmp_path) -> str: + path = str(tmp_path / "vfr_swing.mp4") + generate_vfr_swing(path) + return path + + +@pytest.fixture +def vfr_pts_gap_video(tmp_path) -> str: + path = str(tmp_path / "vfr_pts_gap.mp4") + generate_vfr_pts_gap(path) + return path + + +@pytest.fixture +def vfr_bframes_video(tmp_path) -> str: + path = str(tmp_path / "vfr_bframes.mp4") + generate_vfr_bframes(path) + return path + + +@pytest.fixture +def long_video() -> str: + """Long synthetic video for memory/FD leak stress testing. + + Checked in under tests/resources/ on the resources branch; encode locally + with ffmpeg if missing (see scripts/encode_stress_video.sh or the plan). + """ + path = os.path.join(REPO_ROOT, "tests", "resources", "stress_15min.mp4") + if not os.path.exists(path): + pytest.skip( + "tests/resources/stress_15min.mp4 not present. Generate with: " + 'ffmpeg -f lavfi -i "testsrc2=duration=900:size=640x480:rate=30" ' + "-c:v libx264 -crf 30 -preset slow -pix_fmt yuv420p " + "tests/resources/stress_15min.mp4" + ) + return path + + +@pytest.fixture +def synthetic_matrix_generator(tmp_path): + def _generate(codec: str, container: str, extra_args: list | None = None) -> str: + path = str(tmp_path / f"synthetic_{codec}_{container}.{container}") + generate_synthetic_matrix_video(path, codec, container, extra_args) + return path + + return _generate diff --git a/tests/release/synthetic.py b/tests/release/synthetic.py new file mode 100644 index 00000000..dbfbd02b --- /dev/null +++ b/tests/release/synthetic.py @@ -0,0 +1,99 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Synthetic Video Generation + +Functions to generate synthetic video files using ffmpeg for testing purposes. +""" + +import subprocess + + +def generate_vfr_swing(output_path: str): + """Generates a VFR video with three segments separated by visible luma steps. + + Segments: black @ 1 fps (5s) -> gray @ 60 fps (5s) -> white @ 1 fps (5s). + Solid colors make the cuts unambiguous for ContentDetector; mixed rates + exercise the VFR code path. Boundary timestamps: 5.0s and 10.0s. + """ + cmd = [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "color=black:size=320x240:duration=5:rate=1", + "-f", + "lavfi", + "-i", + "color=gray:size=320x240:duration=5:rate=60", + "-f", + "lavfi", + "-i", + "color=white:size=320x240:duration=5:rate=1", + "-filter_complex", + "[0:v][1:v][2:v]concat=n=3:v=1:a=0", + "-vsync", + "vfr", + output_path, + ] + subprocess.run(cmd, check=True, capture_output=True) + + +def generate_vfr_pts_gap(output_path: str): + """Generates a video where setpts filter drops 3 frames mid-clip.""" + # ffmpeg -f lavfi -i "testsrc2=duration=5:rate=30" -vf "select='not(between(n,30,32))',setpts=N/FRAME_RATE/TB" output.mp4 + # Actually to make it VFR with a gap: + cmd = [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=duration=5:rate=30", + "-vf", + "select='not(between(n,30,32))'", + "-vsync", + "vfr", + output_path, + ] + subprocess.run(cmd, check=True, capture_output=True) + + +def generate_vfr_bframes(output_path: str): + """Generates H.264 video with B-frames to exercise DTS/PTS divergence.""" + cmd = [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=duration=5:rate=30", + "-c:v", + "libx264", + "-bf", + "4", + output_path, + ] + subprocess.run(cmd, check=True, capture_output=True) + + +def generate_synthetic_matrix_video( + output_path: str, codec: str, container: str, extra_args: list | None = None +): + """Generates a video with specific codec and container.""" + input_args = ["-f", "lavfi", "-i", "testsrc2=duration=2:rate=30"] + codec_args = ["-c:v", codec] if codec else [] + if extra_args: + codec_args.extend(extra_args) + + cmd = ["ffmpeg", "-y", *input_args, *codec_args, output_path] + subprocess.run(cmd, check=True, capture_output=True) diff --git a/tests/release/test_backends.py b/tests/release/test_backends.py new file mode 100644 index 00000000..9c0e29f2 --- /dev/null +++ b/tests/release/test_backends.py @@ -0,0 +1,137 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Backend Consistency + +Verifies that all available backends produce consistent cut lists for both CFR and VFR videos. +""" + +import importlib.util +import os +import sys + +import pytest + +from scenedetect import ContentDetector, SceneManager, ThresholdDetector, open_video + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +VIDEOS = [ + # (relative path under repo root, is_vfr) + ("tests/resources/testvideo.mp4", False), + ("tests/resources/goldeneye.mp4", False), + ("tests/resources/goldeneye-vfr.mp4", True), +] + +BACKENDS = ("opencv", "pyav", "moviepy") +_BACKEND_PACKAGE = {"opencv": "cv2", "pyav": "av", "moviepy": "moviepy"} + + +def _installed_backends(): + return [ + name for name in BACKENDS if importlib.util.find_spec(_BACKEND_PACKAGE[name]) is not None + ] + + +@pytest.mark.release +@pytest.mark.parametrize("rel_path,is_vfr", VIDEOS) +def test_cross_backend_consistency(rel_path, is_vfr): + video_path = os.path.join(REPO_ROOT, rel_path) + if not os.path.exists(video_path): + pytest.skip(f"Video {rel_path} not present (needs resources branch).") + + # goldeneye-vfr.mp4 has a ContentDetector cut at 00:01:39.474 scoring content_val=27.08 + # against the default threshold of 27.0; on macOS arm64 the decoder build can flip it in + # one backend but not the other (first seen with av 18 / opencv-python 5.0), which fails + # the cut-count comparison. Linux/Windows still gate this video across all backends. + # TODO: replace with a stats-based tolerance for cuts scoring within epsilon of threshold. + if sys.platform == "darwin" and is_vfr: + pytest.skip("VFR cross-backend comparison has borderline cuts that flip on macOS") + + backends = _installed_backends() + if is_vfr and "moviepy" in backends: + # MoviePy does not honor per-frame PTS on VFR video - tracked separately + # from the OpenCV/PyAV VFR path that this test gates. + backends = [b for b in backends if b != "moviepy"] + if len(backends) < 2: + pytest.skip(f"Need at least two backends, have: {backends}") + + results = {} + for backend in backends: + try: + video = open_video(video_path, backend=backend) + except Exception as exc: + pytest.skip(f"{backend} failed to open {rel_path}: {exc}") + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video) + scenes = sm.get_scene_list() + if is_vfr: + results[backend] = [s[0].seconds for s in scenes[1:]] + else: + results[backend] = [s[0].frame_num for s in scenes[1:]] + + reference = backends[0] + expected = results[reference] + for backend in backends[1:]: + actual = results[backend] + assert len(actual) == len(expected), ( + f"Cut count mismatch: {backend}={len(actual)} vs {reference}={len(expected)}" + ) + if is_vfr: + for a, e in zip(actual, expected, strict=True): + # Tolerance: ~one frame at 30 fps. Plan calls for +/-1 local-frame-duration; + # 50 ms is a conservative superset that still catches real drift. + assert abs(a - e) < 0.05, ( + f"VFR timestamp drift between {backend} and {reference}: {a} vs {e}" + ) + else: + assert actual == expected, ( + f"CFR frame-number mismatch between {backend} and {reference}" + ) + + +@pytest.mark.release +def test_cross_backend_threshold_determinism(): + """detect-threshold cut frames must be backend-deterministic across PyAV/OpenCV/MoviePy. + + Regression coverage for the changelog item: previously the cut could differ by 1 frame + between PyAV and OpenCV when the fade midpoint landed on a `.5` rounding boundary + (PyAV uses sub-microsecond PTS; OpenCV uses millisecond-truncated CAP_PROP_POS_MSEC). + """ + video_path = os.path.join(REPO_ROOT, "tests/resources/fades.mp4") + if not os.path.exists(video_path): + pytest.skip("tests/resources/fades.mp4 not present.") + + backends = _installed_backends() + if len(backends) < 2: + pytest.skip(f"Need at least two backends, have: {backends}") + + results = {} + for backend in backends: + try: + video = open_video(video_path, backend=backend) + except Exception as exc: + pytest.skip(f"{backend} failed to open fades.mp4: {exc}") + sm = SceneManager() + sm.add_detector(ThresholdDetector()) + sm.detect_scenes(video) + # `frame_num` of the first frame of each cut, excluding the implicit 0th cut. + results[backend] = [s[0].frame_num for s in sm.get_scene_list()[1:]] + + reference = backends[0] + expected = results[reference] + for backend in backends[1:]: + actual = results[backend] + assert actual == expected, ( + f"detect-threshold cut frames differ between {backend}={actual} and " + f"{reference}={expected} - the .5-boundary rounding fix has regressed." + ) diff --git a/tests/release/test_cli_permutations.py b/tests/release/test_cli_permutations.py new file mode 100644 index 00000000..9795b25c --- /dev/null +++ b/tests/release/test_cli_permutations.py @@ -0,0 +1,264 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""CLI Permutation Smoke Tests + +Exercises CLI command chains via subprocess. +""" + +import os +import subprocess +import sys + +import pytest + + +def _run(args, cwd): + result = subprocess.run( + [sys.executable, "-m", "scenedetect", *args], + cwd=cwd, + capture_output=True, + text=True, + ) + return result + + +@pytest.mark.release +def test_cli_chain_smoke(test_video_file, tmp_path): + # detect-content save-images list-scenes chain. + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-o", + str(tmp_path), + "detect-content", + "save-images", + "list-scenes", + ], + cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."), + ) + assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}" + csvs = [p for p in tmp_path.iterdir() if p.suffix == ".csv"] + images = [p for p in tmp_path.iterdir() if p.suffix == ".jpg"] + assert csvs, "No scenes CSV produced" + assert images, "No scene images produced" + + +@pytest.mark.release +def test_cli_range_smoke(test_video_file, tmp_path): + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-o", + str(tmp_path), + "time", + "-e", + "2s", + "detect-content", + "list-scenes", + ], + cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."), + ) + assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}" + + +@pytest.mark.release +def test_cli_stats_roundtrip(test_video_file, tmp_path): + stats_path = tmp_path / "stats.csv" + repo_cwd = os.path.abspath(os.path.dirname(test_video_file) + "/../..") + + # First run: generate stats. + run1 = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-s", + str(stats_path), + "-o", + str(tmp_path), + "detect-content", + "list-scenes", + "-f", + "run1", + ], + cwd=repo_cwd, + ) + assert run1.returncode == 0, run1.stderr + assert stats_path.exists() + + # Second run: reuse stats. + run2 = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-s", + str(stats_path), + "-o", + str(tmp_path), + "detect-content", + "list-scenes", + "-f", + "run2", + ], + cwd=repo_cwd, + ) + assert run2.returncode == 0, run2.stderr + + def _cuts(csv_path): + # First line is the cut-list summary; extract it for comparison. + return csv_path.read_text().splitlines()[0] + + assert _cuts(tmp_path / "run1.csv") == _cuts(tmp_path / "run2.csv"), ( + "Cut list differs between stats-producing run and stats-consuming run." + ) + + +@pytest.mark.release +def test_cli_min_scene_len_smoke(test_video_file, tmp_path): + # A min-scene-len longer than the video collapses everything to a single scene. + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-o", + str(tmp_path), + "detect-content", + "--min-scene-len", + "1000s", + "list-scenes", + ], + cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."), + ) + assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}" + + +@pytest.mark.release +def test_cli_save_fcp_smoke(test_video_file, tmp_path): + """save-fcp writes a well-formed Final Cut Pro XML.""" + import xml.etree.ElementTree as ET + + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-o", + str(tmp_path), + "detect-content", + "save-fcp", + ], + cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."), + ) + assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}" + xml_files = [p for p in tmp_path.iterdir() if p.suffix == ".xml"] + assert xml_files, "save-fcp produced no .xml file" + # Parse must succeed; root or depending on the FCP variant. + root = ET.parse(xml_files[0]).getroot() + assert root.tag in ("fcpxml", "xmeml"), f"Unexpected root element: {root.tag}" + + +@pytest.mark.release +def test_cli_save_qp_smoke(test_video_file, tmp_path): + """save-qp writes a QP file with ` I ` lines for scene boundaries.""" + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-o", + str(tmp_path), + "detect-content", + "save-qp", + ], + cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."), + ) + assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}" + qp_files = [p for p in tmp_path.iterdir() if p.suffix == ".qp"] + assert qp_files, "save-qp produced no .qp file" + contents = qp_files[0].read_text().strip() + assert contents, "save-qp produced an empty file" + # Each line must be ` I ` where shift is an integer. + for line in contents.splitlines(): + parts = line.split() + assert len(parts) == 3 and parts[0].isdigit() and parts[1] == "I", ( + f"Malformed QP line: {line!r}" + ) + int(parts[2]) # shift must parse as int (may be negative) + + +@pytest.mark.release +def test_cli_save_html_smoke(test_video_file, tmp_path): + """save-html replaces the deprecated export-html and produces an HTML report. + + Note: save-html lacks its own --output option and ignores the global -o, so the + file is routed via --filename with an absolute path. + """ + out_html = tmp_path / "scenes.html" + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "detect-content", + "save-html", + "--filename", + str(out_html), + "--no-images", + ], + cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."), + ) + assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}" + assert out_html.exists(), f"save-html produced no file at {out_html}" + contents = out_html.read_text(encoding="utf-8") + # The output is an HTML fragment (a of scenes), not a full document. + lowered = contents.lower() + assert "" in lowered, ( + f"save-html output is missing the scenes
:\n{contents[:500]}" + ) + + +@pytest.mark.release +def test_cli_save_edl_start_timecode_smoke(test_video_file, tmp_path): + """save-edl --start-timecode produces an EDL where event timestamps are offset by the + requested start. Both SMPTE (HH:MM:SS:FF) and 8-digit (HHMMSSFF) inputs must be accepted.""" + repo_cwd = os.path.abspath(os.path.dirname(test_video_file) + "/../..") + + def _edl(form: str, out_dir): + result = _run( + [ + "-i", + os.path.abspath(test_video_file), + "-o", + str(out_dir), + "detect-content", + "save-edl", + "--start-timecode", + form, + ], + cwd=repo_cwd, + ) + assert result.returncode == 0, ( + f"start-timecode {form!r} failed:\nstderr:\n{result.stderr}\nstdout:\n{result.stdout}" + ) + edls = [p for p in out_dir.iterdir() if p.suffix == ".edl"] + assert edls, f"save-edl --start-timecode {form!r} produced no .edl file" + return edls[0].read_text() + + # SMPTE form. + smpte_dir = tmp_path / "smpte" + smpte_dir.mkdir() + smpte_text = _edl("01:00:00:00", smpte_dir) + # 8-digit form (semantically equivalent to 01:00:00:00). + digit_dir = tmp_path / "digit" + digit_dir.mkdir() + digit_text = _edl("01000000", digit_dir) + # Both EDLs must contain at least one event line with the 01:00:... offset visible. + assert "01:00:" in smpte_text, f"SMPTE start TC not propagated to EDL:\n{smpte_text}" + assert "01:00:" in digit_text, f"8-digit start TC not propagated to EDL:\n{digit_text}" diff --git a/tests/release/test_golden.py b/tests/release/test_golden.py new file mode 100644 index 00000000..289fe0f5 --- /dev/null +++ b/tests/release/test_golden.py @@ -0,0 +1,99 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Golden Result Tests + +Verifies that detectors produce the exact same timecodes as stored in the golden JSONs. +""" + +import json +import os +import sys + +import pytest + +from scenedetect import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + SceneManager, + ThresholdDetector, + open_video, +) + +DETECTOR_MAP = { + "ContentDetector": ContentDetector, + "AdaptiveDetector": AdaptiveDetector, + "ThresholdDetector": ThresholdDetector, + "HistogramDetector": HistogramDetector, + "HashDetector": HashDetector, +} + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +GOLDEN_DIR = os.path.join(REPO_ROOT, "tests", "resources", "goldens") + + +def get_golden_files(): + if not os.path.exists(GOLDEN_DIR): + return [] + return sorted(f for f in os.listdir(GOLDEN_DIR) if f.endswith(".json")) + + +@pytest.mark.release +@pytest.mark.parametrize("golden_file", get_golden_files()) +def test_golden_regression(golden_file): + with open(os.path.join(GOLDEN_DIR, golden_file)) as f: + expected_cuts = json.load(f)["cuts"] + + # Parse filename: video.mp4.DetectorName.suffix.json + parts = golden_file.split(".") + video_name = parts[0] + "." + parts[1] + detector_name = parts[2] + suffix = parts[3] + + video_path = os.path.join(REPO_ROOT, "tests", "resources", video_name) + if not os.path.exists(video_path): + pytest.skip(f"Video {video_path} not found.") + + # TODO: HistogramDetector and AdaptiveDetector diverge on macOS; the decoder pipeline seems to + # produce different YUV bytes and/or there is a math error somewhere. + if sys.platform == "darwin" and detector_name in ("HistogramDetector", "AdaptiveDetector"): + pytest.skip(f"{detector_name} goldens diverge on macOS (decoder/SIMD pipeline)") + + # Known borderline cuts flip on macOS arm64 depending on decoder build (first seen when CI + # moved to av 18 / opencv-python 5.0): goldeneye-vfr.mp4 has a ContentDetector cut at + # 00:01:39.474 scoring content_val=27.08 against the default threshold of 27.0, and + # goldeneye.mp4 flips a HashDetector cut at frame 976. These goldens still match exactly on + # Linux/Windows, which remain the strict gate. + # TODO: replace these skips with a stats-based tolerance that only forgives cuts whose + # detection metric is within epsilon of the detector threshold. + if sys.platform == "darwin" and (video_name, detector_name) in ( + ("goldeneye-vfr.mp4", "ContentDetector"), + ("goldeneye.mp4", "HashDetector"), + ): + pytest.skip(f"{video_name} {detector_name} golden has borderline cuts that flip on macOS") + + detector_class = DETECTOR_MAP[detector_name] + params = {} + if detector_name == "ContentDetector" and suffix == "t30": + params = {"threshold": 30.0} + elif detector_name == "AdaptiveDetector" and suffix == "t5": + params = {"adaptive_threshold": 5.0} + + video = open_video(video_path, backend="pyav") + scene_manager = SceneManager() + scene_manager.add_detector(detector_class(**params)) + scene_manager.detect_scenes(video) + scene_list = scene_manager.get_scene_list() + actual_cuts = [scene[0].frame_num for scene in scene_list[1:]] + + assert actual_cuts == expected_cuts, f"Cut list mismatch for {golden_file}" diff --git a/tests/release/test_input_matrix.py b/tests/release/test_input_matrix.py new file mode 100644 index 00000000..beefeec2 --- /dev/null +++ b/tests/release/test_input_matrix.py @@ -0,0 +1,53 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Codec / Container / Geometry + +Verifies that PySceneDetect can handle various codecs, containers, and video properties. +""" + +import subprocess + +import pytest + +from scenedetect import ContentDetector, SceneManager, open_video + +MATRIX = [ + ("libx264", "mp4", []), + ("libx265", "mkv", []), + ("libvpx-vp9", "webm", []), + ("libx264", "mp4", ["-vf", "transpose=1"]), # Rotation + ("libx264", "mp4", ["-pix_fmt", "yuv400p"]), # Grayscale + ("libx264", "mp4", ["-vf", "scale=3840:2160"]), # 4K UHD + ("libx264", "mp4", ["-vf", "fps=120"]), # 120 fps high frame rate + ("libx265", "mp4", ["-pix_fmt", "yuv420p10le"]), # 10-bit HEVC (HDR-adjacent) +] + + +@pytest.mark.release +@pytest.mark.parametrize("codec, container, extra_args", MATRIX) +@pytest.mark.parametrize("backend", ["opencv", "pyav"]) +def test_synthetic_matrix(synthetic_matrix_generator, codec, container, extra_args, backend): + try: + video_path = synthetic_matrix_generator(codec, container, extra_args) + except subprocess.CalledProcessError: + pytest.skip(f"Codec {codec} or container {container} not supported by ffmpeg.") + + video = open_video(video_path, backend=backend) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video) + + # Ensure it processed some frames + assert video.frame_number > 0 + # Plausible duration + assert video.duration is not None + assert abs(video.duration.seconds - 2.0) < 0.2 diff --git a/tests/release/test_long_video.py b/tests/release/test_long_video.py new file mode 100644 index 00000000..c450b344 --- /dev/null +++ b/tests/release/test_long_video.py @@ -0,0 +1,84 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Long-Video Stress Test + +Verifies no memory leaks or file descriptors during processing of long videos. +""" + +import os +import sys +import threading +import time + +import pytest + +from scenedetect import ContentDetector, SceneManager, open_video + +try: + import psutil + + HAS_PSUTIL = True +except ImportError: + HAS_PSUTIL = False + + +@pytest.mark.release +@pytest.mark.skipif( + sys.platform != "linux", + reason="Long stress test runs on Linux only (num_fds/handles semantics differ elsewhere).", +) +def test_long_video_stress(long_video): + if not HAS_PSUTIL: + pytest.skip("psutil not installed.") + + process = psutil.Process(os.getpid()) + baseline_rss = process.memory_info().rss + peak_rss = [baseline_rss] + stop_event = threading.Event() + + def monitor_memory(): + while not stop_event.is_set(): + try: + current_rss = process.memory_info().rss + if current_rss > peak_rss[0]: + peak_rss[0] = current_rss + except (psutil.NoSuchProcess, psutil.AccessDenied): + break + time.sleep(1) + + monitor_thread = threading.Thread(target=monitor_memory, daemon=True) + monitor_thread.start() + + try: + video = open_video(long_video) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video) + + # Ensure it actually did something + assert video.frame_number > 0 + finally: + stop_event.set() + monitor_thread.join() + + # Assert peak RSS <= 3x baseline + # Some increase is expected due to internal buffering, but not 3x for 480p. + assert peak_rss[0] <= 3 * baseline_rss, ( + f"Memory leak suspected: Peak RSS {peak_rss[0]} > 3x Baseline RSS {baseline_rss}" + ) + + # Check open file descriptors (only works on some platforms easily) + # On Windows it's num_handles + if sys.platform == "win32": + assert process.num_handles() <= 100 # Conservative baseline + else: + assert process.num_fds() <= 50 diff --git a/tests/release/test_validation.py b/tests/release/test_validation.py new file mode 100644 index 00000000..aaccf75b --- /dev/null +++ b/tests/release/test_validation.py @@ -0,0 +1,178 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Output File Validation + +Verifies that output files (videos, images, CSV, EDL, OTIO) are correctly generated and valid. +""" + +import csv +import shutil +import subprocess + +import pytest + +from scenedetect import ( + ContentDetector, + SceneManager, + open_video, + split_video_ffmpeg, +) +from scenedetect.output import save_images, write_scene_list, write_scene_list_otio + +try: + from PIL import Image + + HAS_PIL = True +except ImportError: + HAS_PIL = False + +try: + import opentimelineio as otio + + HAS_OTIO = True +except ImportError: + HAS_OTIO = False + + +def _detect(video_path): + video = open_video(video_path) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video) + return video, sm.get_scene_list() + + +@pytest.mark.release +def test_output_csv_roundtrip(test_video_file, tmp_path): + _video, scene_list = _detect(test_video_file) + csv_path = str(tmp_path / "scenes.csv") + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list, include_cut_list=False) + + with open(csv_path) as f: + rows = list(csv.DictReader(f)) + assert len(rows) == len(scene_list) + # write_scene_list emits 1-based start frames; reverse the offset. + assert int(rows[0]["Start Frame"]) - 1 == scene_list[0][0].frame_num + + +@pytest.mark.release +def test_output_image_extensions(test_video_file, tmp_path): + if not HAS_PIL: + pytest.skip("Pillow not installed.") + video, scene_list = _detect(test_video_file) + # Limit to the first two scenes to keep the test fast. + scene_list = scene_list[:2] + + for ext in ("jpg", "png", "webp"): + out_dir = tmp_path / f"images_{ext}" + out_dir.mkdir() + save_images( + scene_list, + video, + num_images=1, + output_dir=str(out_dir), + image_extension=ext, + show_progress=False, + ) + files = [p for p in out_dir.iterdir() if p.suffix == f".{ext}"] + assert files, f"No {ext} images produced" + for p in files: + with Image.open(p) as img: + img.verify() + assert img.size[0] > 0 and img.size[1] > 0 + + +@pytest.mark.release +def test_output_otio_rational_time_precision(test_video_file, tmp_path): + if not HAS_OTIO: + pytest.skip("opentimelineio not installed.") + video, scene_list = _detect(test_video_file) + otio_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=otio_path, + scene_list=scene_list, + video_path=test_video_file, + frame_rate=video.frame_rate, + ) + + timeline = otio.adapters.read_from_file(str(otio_path)) + # One clip per scene, on each track (video + audio by default). + video_track = timeline.tracks[0] + assert len(list(video_track)) == len(scene_list) + + # `value` is a frame count derived from seconds * fps, serialized at 10us + # precision (round(..., 6)) per 914ca31. Guards the `90.00000000000001` class + # of float-cast drift by asserting the rounded value never carries spurious + # sub-10us noise. + for clip in video_track: + for rt in (clip.source_range.start_time, clip.source_range.duration): + assert abs(rt.value - round(rt.value, 6)) == 0, ( + f"RationalTime.value lost precision: {rt.value!r}" + ) + + +@pytest.mark.release +def test_input_path_unicode(test_video_file, tmp_path): + """All backends must open videos at non-ASCII filesystem paths. This is a silent failure + mode on platforms with mbcs default codecs; the failure mode is "video just won't open" + rather than a clear error. Worth a release-level smoke test.""" + nonascii_dir = tmp_path / "vidéos日本語" + nonascii_dir.mkdir() + nonascii_video = nonascii_dir / "café_テスト.mp4" + shutil.copy(test_video_file, nonascii_video) + + for backend in ("opencv", "pyav"): + try: + video = open_video(str(nonascii_video), backend=backend) + except Exception as exc: + pytest.fail(f"Failed to open non-ASCII path {nonascii_video} via {backend}: {exc}") + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video) + # Detection should succeed end-to-end; we don't care about the exact scene count, just + # that the backend made it through the read loop without bailing out silently. + assert video.frame_number > 0, ( + f"Backend {backend} read 0 frames from {nonascii_video} - silent path-decode failure?" + ) + + +@pytest.mark.release +def test_output_split_video(test_video_file, tmp_path): + _video, scene_list = _detect(test_video_file) + # Split only the first two scenes to bound the runtime. + scene_list = scene_list[:2] + out_dir = tmp_path / "splits" + out_dir.mkdir() + output_template = str(out_dir / "scene-$SCENE_NUMBER.mp4") + split_video_ffmpeg(test_video_file, scene_list, output_file_template=output_template) + + split_files = sorted(out_dir.glob("*.mp4")) + assert len(split_files) == len(scene_list) + + for path in split_files: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + check=True, + ) + assert float(result.stdout.strip()) > 0 diff --git a/tests/release/test_vfr.py b/tests/release/test_vfr.py new file mode 100644 index 00000000..3ad58408 --- /dev/null +++ b/tests/release/test_vfr.py @@ -0,0 +1,96 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""VFR Accuracy Against Ground Truth + +Verifies that scene cuts in synthetic VFR videos are detected at the correct +wall-clock times. +""" + +import pytest + +from scenedetect import ContentDetector, SceneManager, open_video + + +@pytest.mark.release +@pytest.mark.parametrize("backend", ["opencv", "pyav"]) +def test_vfr_swing_accuracy(vfr_swing_video, backend): + video = open_video(vfr_swing_video, backend=backend) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video) + scene_list = scene_manager.get_scene_list() + + # Ground truth: cuts at 5.0s and 10.0s + assert len(scene_list) == 3 + + # Tolerance: 1 frame at the local rate. + # At 5.0s, the rate changes from 1 fps to 60 fps. + # At 10.0s, it changes from 60 fps to 1 fps. + # We'll use a conservative 100ms tolerance. + assert abs(scene_list[1][0].seconds - 5.0) < 0.1 + assert abs(scene_list[2][0].seconds - 10.0) < 0.1 + + +@pytest.mark.release +@pytest.mark.parametrize("backend", ["opencv", "pyav"]) +def test_vfr_pts_gap_accuracy(vfr_pts_gap_video, backend): + video = open_video(vfr_pts_gap_video, backend=backend) + # We don't expect a cut here necessarily, but we want to ensure it doesn't crash + # and duration is reported correctly. + # testsrc2 duration=5:rate=30 is 150 frames. + # We drop 3 frames (30, 31, 32). Remaining: 147 frames. + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video) + + # Some backends might report duration differently if there's a gap. + # For now, just ensure it runs. + assert video.duration is not None + assert video.duration.seconds > 0 + + +@pytest.mark.release +@pytest.mark.parametrize("backend", ["opencv", "pyav"]) +def test_vfr_bframes_accuracy(vfr_bframes_video, backend): + video = open_video(vfr_bframes_video, backend=backend) + # Ensure B-frames don't cause issues with frame ordering or detection + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video) + + assert video.duration is not None + assert video.duration.seconds > 0 + + +@pytest.mark.release +def test_vfr_swing_cross_backend_parity(vfr_swing_video): + """OpenCV and PyAV must agree on cuts in a synthetic VFR clip with known ground truth. + + MoviePy is excluded because it does not honor per-frame PTS on VFR sources (already + skipped in test_cross_backend_consistency for the same reason). + """ + results: dict[str, list[float]] = {} + for backend in ("opencv", "pyav"): + video = open_video(vfr_swing_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video) + results[backend] = [s[0].seconds for s in sm.get_scene_list()] + + assert len(results["opencv"]) == len(results["pyav"]), ( + f"Scene count mismatch: opencv={len(results['opencv'])}, pyav={len(results['pyav'])}" + ) + # Tolerance: 50ms (well below one frame at the 1fps and 60fps regions of the swing clip). + for cv_t, av_t in zip(results["opencv"], results["pyav"], strict=True): + assert abs(cv_t - av_t) < 0.05, ( + f"VFR-swing scene start drifted between backends: opencv={cv_t}, pyav={av_t}" + ) diff --git a/tests/test_api.py b/tests/test_api.py index e86243ad..5bde6cfd 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. # @@ -13,6 +13,8 @@ These tests demonstrate common workflow patterns used when integrating the PySceneDetect API.""" +import pytest + def test_api_detect(test_video_file: str): """Demonstrate usage of the `detect()` function to process a complete video.""" @@ -20,7 +22,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 +33,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 +53,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 +71,22 @@ 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_open_video_framerate_legacy_alias(test_video_file: str): + """`open_video(framerate=...)` is the deprecated alias for `frame_rate=` (issue #548). + Both forms must produce equivalent streams; when both are provided, `frame_rate` wins.""" + from scenedetect import open_video + + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy = open_video(test_video_file, framerate=30.0) + canonical = open_video(test_video_file, frame_rate=30.0) + assert legacy.frame_rate == canonical.frame_rate + # `frame_rate` takes precedence over `framerate` when both are provided. + with pytest.warns(DeprecationWarning, match="frame_rate"): + both = open_video(test_video_file, frame_rate=30.0, framerate=24.0) + assert both.frame_rate == canonical.frame_rate def test_api_timecode_types(): @@ -100,7 +117,8 @@ 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" + assert scene_manager.stats_manager is not None scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -108,11 +126,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("New scene found at frame %d." % 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 +145,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("New scene found at frame %d." % 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) @@ -142,3 +160,28 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video, duration=total_frames, callback=on_new_scene) + + +# TODO(v0.8): Remove this test when these deprecated modules are removed from the codebase. +def test_deprecated_modules_emits_warning_on_import(): + import importlib + + import pytest + + SCENE_DETECTOR_WARNING = ( + "The `scene_detector` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): + importlib.import_module("scenedetect.scene_detector") + + FRAME_TIMECODE_WARNING = ( + "The `frame_timecode` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): + importlib.import_module("scenedetect.frame_timecode") + + VIDEO_SPLITTER_WARNING = ( + "The `video_splitter` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): + importlib.import_module("scenedetect.video_splitter") diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index d1d66020..9aefca78 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. # @@ -18,6 +18,7 @@ """ import cv2 +import pytest from scenedetect import ContentDetector, SceneManager from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 @@ -28,9 +29,10 @@ def test_open_image_sequence(test_image_sequence: str): """Test opening an image sequence. Currently, only VideoStreamCv2 supports this.""" - sequence = VideoStreamCv2(test_image_sequence, framerate=25.0) + sequence = VideoStreamCv2(test_image_sequence, frame_rate=25.0) assert sequence.is_seekable assert sequence.frame_size[0] > 0 and sequence.frame_size[1] > 0 + assert sequence.duration is not None assert sequence.duration.frame_num == 30 assert sequence.read() is not False sequence.seek(100) @@ -50,3 +52,40 @@ def test_capture_adapter(test_movie_clip: str): scenes = scene_manager.get_scene_list() assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST) assert [start.frame_num for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST + + +def test_capture_adapter_framerate_legacy_alias(test_movie_clip: str): + """`framerate=` is the deprecated alias for `frame_rate=` on VideoCaptureAdapter.""" + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy = VideoCaptureAdapter(cap, framerate=30.0) + + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + canonical = VideoCaptureAdapter(cap, frame_rate=30.0) + assert canonical.frame_rate == legacy.frame_rate + + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + with pytest.warns(DeprecationWarning, match="frame_rate"): + both = VideoCaptureAdapter(cap, frame_rate=30.0, framerate=24.0) + assert both.frame_rate == canonical.frame_rate + + +def test_decode_failures_exposed(corrupt_video_file: str): + """The private decode failure counters must be surfaced by the public property on both + VideoStreamCv2 and VideoCaptureAdapter.""" + stream = VideoStreamCv2(corrupt_video_file) + while stream.read(decode=False) is not False: + pass + assert stream.decode_failures == stream._decode_failures + assert stream.decode_failures >= 0 + + cap = cv2.VideoCapture(corrupt_video_file) + assert cap.isOpened() + adapter = VideoCaptureAdapter(cap) + while adapter.read(decode=False) is not False: + pass + assert adapter.decode_failures == adapter._decode_failures + assert adapter.decode_failures >= 0 diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index 8e27a495..17bf0170 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. # @@ -17,15 +17,77 @@ For VideoStream tests that validate conformance, see test_video_stream.py. """ -from scenedetect.backends.pyav import VideoStreamAv +import av +from scenedetect.backends.pyav import MAX_CONSECUTIVE_DECODE_FAILURES, VideoStreamAv -def test_video_stream_pyav_bytesio(test_video_file: str): + +def test_video_stream_pyav_bytesio(test_video_file: str, auto_close): """Test that VideoStreamAv works with a BytesIO input in addition to a path.""" # Mode must be binary! with open(test_video_file, mode="rb") as video_file: - stream = VideoStreamAv(path_or_io=video_file, threading_mode=None) + stream = auto_close(VideoStreamAv(path_or_io=video_file, threading_mode=None)) assert stream.is_seekable stream.seek(50) for _ in range(10): assert stream.read() is not False + + +def _make_invalid_data_error() -> Exception: + # AVERROR_INVALIDDATA ("Invalid data found when processing input"). + return av.error.InvalidDataError( # type: ignore[attr-defined] + 1094995529, "Invalid data found when processing input" + ) + + +class _FaultInjectingContainer: + """Wraps an `av.InputContainer`, replacing `decode` to inject decode errors. + `InputContainer.decode` itself is a read-only Cython attribute, so we swap the whole + container for this proxy instead.""" + + def __init__(self, container, decode): + self._container = container + self._decode = decode + + def decode(self, *args, **kwargs): + return self._decode(self._container, *args, **kwargs) + + def __getattr__(self, name): + return getattr(self._container, name) + + +def test_read_tolerates_corrupt_frame(test_video_file: str, auto_close): + """A decode error partway through the stream must be skipped, not stop decoding.""" + stream = auto_close(VideoStreamAv(test_video_file)) + injected = False + + def fault_injecting_decode(container, *args, **kwargs): + nonlocal injected + for frame_index, frame in enumerate(container.decode(*args, **kwargs)): + if not injected and frame_index == 5: + injected = True + raise _make_invalid_data_error() + yield frame + + stream._container = _FaultInjectingContainer(stream._container, fault_injecting_decode) + for frame in range(20): + assert stream.read(decode=False) is not False, f"Failed on frame {frame}!" + assert injected + assert stream.decode_failures == 1 + + +def test_read_gives_up_after_consecutive_failures(test_video_file: str, caplog, auto_close): + """After too many consecutive decode failures, read() must return False, not hang.""" + stream = auto_close(VideoStreamAv(test_video_file)) + + def always_failing_decode(container, *args, **kwargs): + raise _make_invalid_data_error() + yield # pragma: no cover - makes this a generator function. + + stream._container = _FaultInjectingContainer(stream._container, always_failing_decode) + assert stream.read(decode=False) is False + assert stream.decode_failures == MAX_CONSECUTIVE_DECODE_FAILURES + # Giving up emits an ERROR log by design; verify it then clear it so the autouse + # `no_logs_gte_error` fixture doesn't fail the test. + assert any("consecutive" in record.message for record in caplog.records) + caplog.clear() diff --git a/tests/test_benchmark_evaluator.py b/tests/test_benchmark_evaluator.py new file mode 100644 index 00000000..6f6a1784 --- /dev/null +++ b/tests/test_benchmark_evaluator.py @@ -0,0 +1,322 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Unit tests for the benchmark evaluator. Exercises matching, tolerance, and aggregation logic with +synthetic predictions versus ground-truth lists. Covers TRECVID-SBD style scoring as documented in +``benchmark/README.md``. +""" + +from __future__ import annotations + +import math +from pathlib import Path + +from benchmark.evaluator import ( + EventInterval, + EventMetrics, + GroundTruth, + Prediction, + _score_fade_transitions, + _score_hard_cuts, + evaluate, + score_video, +) + +# --------------------------------------------------------------------- # +# Hard-cut matching (the core of every detector's score) +# --------------------------------------------------------------------- # + + +def test_hard_exact_match_tolerance_zero(): + m, offsets = _score_hard_cuts( + predicted_cuts=[10, 20, 30], ground_truth_cuts=[10, 20, 30], tolerance=0 + ) + assert (m.matched, m.false_positives, m.missed) == (3, 0, 0) + assert offsets == [0, 0, 0] + assert m.precision == 1.0 + assert m.recall == 1.0 + assert m.f1 == 1.0 + + +def test_hard_tolerance_one_admits_one_frame_offset(): + m, offsets = _score_hard_cuts(predicted_cuts=[11, 19], ground_truth_cuts=[10, 20], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (2, 0, 0) + assert sorted(offsets) == [1, 1] + + +def test_hard_tolerance_one_rejects_two_frame_offset(): + m, _ = _score_hard_cuts(predicted_cuts=[12], ground_truth_cuts=[10], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (0, 1, 1) + + +def test_hard_greedy_picks_closer_match(): + # Two ground-truth cuts at 10 and 14. Single prediction at 13 is closer + # to 14. The greedy matcher must claim 14 first; 10 then becomes a miss. + m, offsets = _score_hard_cuts(predicted_cuts=[13], ground_truth_cuts=[10, 14], tolerance=5) + assert (m.matched, m.false_positives, m.missed) == (1, 0, 1) + assert offsets == [1] + + +def test_hard_equidistant_tie_resolves_deterministically(): + # Prediction at 12 is exactly 2 frames from both ground-truth cuts at 10 + # and 14. Tie-break is by stable sort order (i, j) which prefers the + # lower ground-truth index. + m, offsets = _score_hard_cuts(predicted_cuts=[12], ground_truth_cuts=[10, 14], tolerance=5) + assert (m.matched, m.false_positives, m.missed) == (1, 0, 1) + assert offsets == [2] + + +def test_hard_one_to_one_no_double_assignment(): + # Two predictions both within tolerance of a single ground-truth cut. + # Only one can match; the other is a false positive. + m, _ = _score_hard_cuts(predicted_cuts=[10, 11], ground_truth_cuts=[10], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (1, 1, 0) + + +def test_hard_empty_inputs(): + m, _ = _score_hard_cuts(predicted_cuts=[], ground_truth_cuts=[], tolerance=0) + assert (m.matched, m.false_positives, m.missed) == (0, 0, 0) + # Division-by-zero defenses. + assert m.precision == 0.0 + assert m.recall == 0.0 + assert m.f1 == 0.0 + + +def test_hard_empty_preds_with_nonempty_gt(): + # No predictions: every ground-truth cut is a miss. + m, offsets = _score_hard_cuts(predicted_cuts=[], ground_truth_cuts=[10, 20], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (0, 0, 2) + assert offsets == [] + assert m.recall == 0.0 + + +def test_hard_empty_gt_with_nonempty_preds(): + # No ground truth: every prediction is a false positive. + m, offsets = _score_hard_cuts(predicted_cuts=[10], ground_truth_cuts=[], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (0, 1, 0) + assert offsets == [] + assert m.precision == 0.0 + + +# --------------------------------------------------------------------- # +# Fade transition matching (ClipShots-style typed ground truth) +# --------------------------------------------------------------------- # + + +def test_fade_pred_inside_interval_is_match(): + m, consumed = _score_fade_transitions(predicted_cuts=[15], intervals=[EventInterval(10, 20)]) + assert (m.matched, m.false_positives, m.missed) == (1, 0, 0) + assert consumed == {0} + + +def test_fade_pred_outside_interval_not_consumed(): + m, consumed = _score_fade_transitions(predicted_cuts=[25], intervals=[EventInterval(10, 20)]) + assert (m.matched, m.false_positives, m.missed) == (0, 0, 1) + assert consumed == set() # passed through to hard scorer + + +def test_fade_multiple_preds_in_same_interval(): + # First prediction inside the interval is the match; the second is a + # false positive. Both are consumed (do not leak to hard matching). + m, consumed = _score_fade_transitions( + predicted_cuts=[12, 18], intervals=[EventInterval(10, 20)] + ) + assert (m.matched, m.false_positives, m.missed) == (1, 1, 0) + assert consumed == {0, 1} + + +def test_fade_interval_endpoints_inclusive(): + m, _ = _score_fade_transitions(predicted_cuts=[10, 20], intervals=[EventInterval(10, 20)]) + # Both endpoints hit the same interval, so 1 match + 1 false positive. + assert (m.matched, m.false_positives, m.missed) == (1, 1, 0) + + +# --------------------------------------------------------------------- # +# score_video: fade transitions take priority over hard cuts +# --------------------------------------------------------------------- # + + +def test_score_video_fade_consumes_pred_before_hard(): + # Prediction at 15 falls inside the fade interval [10, 20]. Even + # though the hard ground-truth cut at 16 is within tolerance, the + # fade scorer claims the prediction first and the hard scorer + # never sees it. + ground_truth = GroundTruth(hard_cuts=[16], fades=[EventInterval(10, 20)]) + v = score_video([15], ground_truth, tolerance=1, elapsed=0.0) + assert v.fades.matched == 1 + assert v.hard_cuts.matched == 0 # hard match was preempted by the fade + assert v.hard_cuts.missed == 1 # hard ground-truth cut at 16 is now a miss + + +def test_score_video_pred_outside_fade_falls_to_hard(): + ground_truth = GroundTruth(hard_cuts=[30], fades=[EventInterval(10, 20)]) + v = score_video([30], ground_truth, tolerance=0, elapsed=0.0) + assert v.fades.matched == 0 + assert v.fades.missed == 1 # fade still missed + assert v.hard_cuts.matched == 1 + + +# --------------------------------------------------------------------- # +# Mean absolute offset (localization error on hard-cut matches only) +# --------------------------------------------------------------------- # + + +def test_mean_abs_offset_only_hard_matches_tolerance_zero(): + ground_truth = GroundTruth( + hard_cuts=[100, 200, 300], + fades=[EventInterval(50, 60)], + ) + # Predictions: fade hit at 55 (excluded from offset), hard match at 100 + # (offset 0). 201 and 302 are outside tolerance 0. + v = score_video([55, 100, 201, 302], ground_truth, 0, 0.0) + assert v.hard_cuts.matched == 1 + assert v.mean_abs_offset == 0.0 + + +def test_mean_abs_offset_only_hard_matches_tolerance_one(): + ground_truth = GroundTruth( + hard_cuts=[100, 200, 300], + fades=[EventInterval(50, 60)], + ) + # Same setup at tolerance 1: 100 (offset 0) and 201 (offset 1) match; + # 302 is out of tolerance. Mean offset is (0 + 1) / 2 = 0.5. + v = score_video([55, 100, 201, 302], ground_truth, 1, 0.0) + assert v.hard_cuts.matched == 2 + assert v.mean_abs_offset == 0.5 + + +def test_mean_abs_offset_nan_when_no_matches(): + ground_truth = GroundTruth(hard_cuts=[1000]) + v = score_video([5], ground_truth, 0, 0.0) + assert math.isnan(v.mean_abs_offset) + + +def test_benchmark_result_mean_abs_offset_nan_when_no_matches_across_videos(): + # Two videos, both producing zero hard-cut matches. The aggregate offset + # has zero sum and zero count, so nan must propagate at the + # BenchmarkResult level, not just per-video. + predictions = { + Path("a.mp4"): Prediction( + predicted_cuts=[5], + ground_truth=GroundTruth(hard_cuts=[1000]), + elapsed=1.0, + ), + Path("b.mp4"): Prediction( + predicted_cuts=[7], + ground_truth=GroundTruth(hard_cuts=[2000]), + elapsed=1.0, + ), + } + result = evaluate(predictions, tolerance=0) + assert math.isnan(result.mean_abs_offset_hard_cuts) + + +# --------------------------------------------------------------------- # +# Aggregate result: sum-of-counts across videos +# --------------------------------------------------------------------- # + + +def test_benchmark_result_aggregate_matches_sum_of_counts(): + predictions = { + Path("vid_a.mp4"): Prediction( + predicted_cuts=[10, 20], + ground_truth=GroundTruth(hard_cuts=[10, 21]), + elapsed=1.0, + ), + Path("vid_b.mp4"): Prediction( + predicted_cuts=[50, 99], + ground_truth=GroundTruth(hard_cuts=[50, 100]), + elapsed=3.0, + ), + } + # Tolerance 0: only 10 (vid_a) and 50 (vid_b) match exactly. + # Aggregate: 2 matched, 2 false positives, 2 missed. + result_t0 = evaluate(predictions, tolerance=0) + assert result_t0.hard_cuts.matched == 2 + assert result_t0.hard_cuts.false_positives == 2 + assert result_t0.hard_cuts.missed == 2 + # Tolerance 1: both predictions in each video match -> 4 matched, 0 fp, 0 missed. + result_t1 = evaluate(predictions, tolerance=1) + assert result_t1.hard_cuts.matched == 4 + assert result_t1.hard_cuts.false_positives == 0 + assert result_t1.hard_cuts.missed == 0 + # Elapsed: total and mean (independent of tolerance). + assert result_t0.elapsed_total == 4.0 + assert result_t0.elapsed_mean == 2.0 + + +def test_benchmark_result_by_category_buckets_videos(): + predictions = { + Path("a.mp4"): Prediction( + predicted_cuts=[10], + ground_truth=GroundTruth(hard_cuts=[10], category="news"), + elapsed=1.0, + ), + Path("b.mp4"): Prediction( + predicted_cuts=[20], + ground_truth=GroundTruth(hard_cuts=[20], category="sports"), + elapsed=1.0, + ), + Path("c.mp4"): Prediction( + predicted_cuts=[30], + ground_truth=GroundTruth(hard_cuts=[30], category="news"), + elapsed=1.0, + ), + } + result = evaluate(predictions, tolerance=0) + by_category = result.by_category() + assert set(by_category) == {"news", "sports"} + assert len(by_category["news"].per_video) == 2 + assert len(by_category["sports"].per_video) == 1 + + +def test_benchmark_result_by_category_buckets_untagged_videos_as_unknown(): + # Datasets without category tags (BBC, AutoShot) leave category=None on every + # video. by_category must bucket those under the literal key "unknown". + predictions = { + Path("a.mp4"): Prediction( + predicted_cuts=[10], + ground_truth=GroundTruth(hard_cuts=[10]), # category defaults to None + elapsed=1.0, + ), + Path("b.mp4"): Prediction( + predicted_cuts=[20], + ground_truth=GroundTruth(hard_cuts=[20]), + elapsed=1.0, + ), + } + result = evaluate(predictions, tolerance=0) + by_category = result.by_category() + assert set(by_category) == {"unknown"} + assert len(by_category["unknown"].per_video) == 2 + + +# --------------------------------------------------------------------- # +# EventMetrics arithmetic +# --------------------------------------------------------------------- # + + +def test_event_metrics_addition(): + a = EventMetrics(matched=3, false_positives=1, missed=2) + b = EventMetrics(matched=5, false_positives=2, missed=1) + c = a + b + assert (c.matched, c.false_positives, c.missed) == (8, 3, 3) + + +def test_event_metrics_to_dict_round_trip(): + m = EventMetrics(matched=3, false_positives=1, missed=1) + d = m.to_dict() + assert d["matched"] == 3 + assert d["false_positives"] == 1 + assert d["missed"] == 1 + assert d["precision"] == 75.0 # 3 / 4 + assert d["recall"] == 75.0 # 3 / 4 + assert d["f1"] == 75.0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 29fd2738..7b22a34d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,44 +5,43 @@ # [ 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. # import os import subprocess -import typing as ty -from pathlib import Path - -import cv2 -import numpy as np -import pytest - -import scenedetect -from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available # These tests validate that the CLI itself functions correctly, mainly based on the return # return code from the process. We do not yet check for correctness of the output, just a # successful invocation of the command (i.e. no exceptions/errors). - # TODO: Add some basic correctness tests to validate the output (just look for the # last expected log message or extract # of scenes). Might need to refactor the test cases # since we need to calculate the output file names for commands that write to disk. - # TODO: Define error/exit codes explicitly. Right now these tests only verify that the # exit code is zero or nonzero. - # TODO: These tests are very expensive since they spin up new Python interpreters. # Move most of these test cases (e.g. argument validation) to ones that interface directly # with the scenedetect._cli module. Click also supports unit testing directly, so we should # probably use that instead of spinning up new subprocesses for each run of the controller. # That will also allow splitting up the validation of argument parsing logic from the controller # logic by creating a CLI context with the desired parameters. - # TODO: Missing tests for --min-scene-len and --drop-short-scenes. +import sys +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +import scenedetect +from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available +from scenedetect.platform import StrPath +from tests.helpers import invoke_cli + +SCENEDETECT_CMD = sys.executable + " -m scenedetect" -SCENEDETECT_CMD = "python -m scenedetect" ALL_DETECTORS = [ "detect-content", "detect-threshold", @@ -68,8 +67,8 @@ def invoke_scenedetect( args: str = "", - output_dir: ty.Optional[str] = None, - config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, + output_dir: StrPath | None = None, + config_file: str | None = DEFAULT_CONFIG_FILE, **kwargs, ): """Invokes the scenedetect CLI with the specified arguments and returns the exit code. @@ -99,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(" ")) @@ -168,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 @@ -196,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 @@ -241,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 @@ -253,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 ( @@ -303,16 +335,98 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): # and ensuring that we got some frames. +@pytest.mark.parametrize( + "option", + ["--frame-rate", "--framerate", "-f"], +) +def test_cli_frame_rate_aliases(option: str): + """All frame-rate aliases are accepted by the CLI.""" + exit_code, _ = invoke_cli( + ["-i", DEFAULT_VIDEO_PATH, option, "30.0", "time", "-s", "2s", "-d", "4s"] + ) + assert exit_code == 0 + + +@pytest.mark.parametrize( + ("options", "succeeds"), + [ + (["--frame-rate", "30.0", "--framerate", "0"], False), + (["--framerate", "0", "--frame-rate", "30.0"], True), + ], +) +def test_cli_frame_rate_aliases_last_value_wins(options: list[str], succeeds: bool): + """When a frame-rate option is repeated, only the last value is validated and used.""" + exit_code, _ = invoke_cli(["-i", DEFAULT_VIDEO_PATH, *options, "time", "-s", "2s", "-d", "4s"]) + assert (exit_code == 0) is succeeds + + +def test_cli_framerate_alias_is_visible(): + """Help shows all frame-rate aliases as one option.""" + exit_code, output = invoke_cli(["--help"]) + + assert exit_code == 0 + assert "-f, --frame-rate, --framerate FPS" in output + + +def test_cli_min_scene_len_accepts_all_timecode_forms(tmp_path: Path): + """`--min-scene-len` (and equivalent options) must accept frames, seconds, and timecodes + in v0.7 per the changelog. The four forms below all resolve to ~20 frames at 23.976 fps + and must produce byte-identical scene lists.""" + # 20 frames @ 23.976 fps = 0.8341... s, which rounds to the same nearest frame regardless + # of which form is parsed. + forms = ["20", "0.834", "0.834s", "00:00:00.834"] + outputs = [] + for form in forms: + out = tmp_path / f"scenes_{form.replace(':', '_')}.csv" + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "--min-scene-len", + form, + "list-scenes", + "-f", + out.name, + "-q", # suppress stdout printing + ], + ) + assert exit_code == 0, f"--min-scene-len {form!r} rejected" + assert out.exists(), f"--min-scene-len {form!r} did not produce {out}" + outputs.append((form, out.read_text())) + # All forms must produce the same scene list. + base_form, base_csv = outputs[0] + for form, csv in outputs[1:]: + assert csv == base_csv, ( + f"Scene list differs between --min-scene-len {base_form!r} and {form!r}" + ) + + def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" - # Regular invocation - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "list-scenes", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") assert os.path.exists(output_path) EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754 @@ -497,7 +611,8 @@ 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 is not None assert image.shape == (544, 1280, 3) @@ -505,8 +620,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 @@ -517,6 +633,7 @@ def test_cli_save_images_path_handling(tmp_path: Path): # Check the created images can be read and have the correct size. # We can't use `cv2.imread` here since it doesn't seem to work correctly with UTF-8 paths. image = cv2.imdecode(np.fromfile(images[0], dtype=np.uint8), cv2.IMREAD_UNCHANGED) + assert image is not None assert image.shape == (544, 1280, 3) @@ -535,7 +652,8 @@ 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])) + assert image is not None # Note same resolution as in test_cli_save_images but rotated 90 degrees. assert image.shape == (1280, 544, 3) @@ -548,10 +666,64 @@ def test_cli_save_html(tmp_path: Path): invoke_scenedetect(base_command, COMMAND="save-html --no-images", output_dir=tmp_path) == 0 ) # Ensure we can still call the now deprecated export-html command. - assert invoke_scenedetect(base_command, COMMAND="save-html", output_dir=tmp_path) == 0 + assert invoke_scenedetect(base_command, COMMAND="export-html", output_dir=tmp_path) == 0 # TODO: Check for existence of HTML & image files. +def test_cli_moviepy_accepts_frame_rate_override(): + """The MoviePy backend supports the -f/--frame-rate override in v0.7. The CLI must run + end-to-end without raising NotImplementedError, and the override must be reflected in the + resulting frame rate.""" + from fractions import Fraction + + from scenedetect.backends.moviepy import VideoStreamMoviePy + + # Direct backend invocation: confirm the frame_rate property reports the override. + vs = VideoStreamMoviePy("tests/resources/testvideo.mp4", frame_rate=15.0) + assert vs.frame_rate == Fraction(15, 1), ( + f"MoviePy frame_rate override not honored: got {vs.frame_rate}" + ) + + # CLI invocation must run cleanly with `-b moviepy -f 30`. + exit_code, output = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-b", + "moviepy", + "--frame-rate", + "30", + "time", + "--end", + "1s", + "detect-content", + ], + ) + assert exit_code == 0, f"CLI failed:\n{output}" + assert "NotImplementedError" not in output, ( + f"Backend NotImplementedError leaked to user output:\n{output}" + ) + + +def test_cli_legacy_v06_config_file(tmp_path: Path): + """A v0.6-era scenedetect.cfg using the deprecated `[export-html]` section must still load + in v0.7. The parser maps `[export-html]` -> `[save-html]` (via DEPRECATED_COMMANDS in + scenedetect/_cli/config.py) and emits a deprecation warning on load. This is the most + likely silent break for users upgrading config files; the option set under both sections + is identical.""" + legacy_cfg = tmp_path / "scenedetect.cfg" + legacy_cfg.write_text( + # Mix of unchanged sections and the renamed `[export-html]` section. + "[global]\nmin-scene-len = 0.6s\n\n" + "[detect-content]\nthreshold = 27\n\n" + "[export-html]\nfilename = $VIDEO_NAME-Scenes.html\nno-images = yes\n" + ) + exit_code, output = invoke_cli( + ["-c", str(legacy_cfg), "-i", DEFAULT_VIDEO_PATH, "time", "-s", "2s", "-d", "1s"], + ) + assert exit_code == 0, f"v0.6-style config rejected:\n{output}" + + def test_cli_save_qp(tmp_path: Path): """Test `save-qp` command with and without a custom filename format.""" EXPECTED_QP_CONTENTS = """ @@ -659,8 +831,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", @@ -701,8 +873,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", @@ -718,8 +890,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", @@ -744,13 +916,22 @@ def test_cli_load_scenes_round_trip(): def test_cli_save_edl(tmp_path: Path): """Test `save-edl` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-edl", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-edl", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.edl") assert os.path.exists(output_path) EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} @@ -765,13 +946,28 @@ def test_cli_save_edl(tmp_path: Path): def test_cli_save_edl_with_params(tmp_path: Path): """Test `save-edl` command but override the other options.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-edl -t title -r BX -f file_no_ext", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-edl", + "-t", + "title", + "-r", + "BX", + "-f", + "file_no_ext", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath("file_no_ext") assert os.path.exists(output_path) EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} @@ -786,13 +982,22 @@ def test_cli_save_edl_with_params(tmp_path: Path): def test_cli_save_otio(tmp_path: Path): """Test `save-otio` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-otio", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-otio", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") assert os.path.exists(output_path) EXPECTED_OTIO_OUTPUT = """{ @@ -994,13 +1199,23 @@ def test_cli_save_otio(tmp_path: Path): def test_cli_save_otio_no_audio(tmp_path: Path): """Test `save-otio` command without audio.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-otio --no-audio", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-otio", + "--no-audio", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") assert os.path.exists(output_path) EXPECTED_OTIO_OUTPUT = """{ @@ -1110,3 +1325,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_concat.py b/tests/test_concat.py new file mode 100644 index 00000000..9e3c18b7 --- /dev/null +++ b/tests/test_concat.py @@ -0,0 +1,177 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""PySceneDetect scenedetect.backends.concat Tests + +Validates the multi-video concatenation logic in `scenedetect.backends.concat`.""" + +import pytest + +from scenedetect import SceneManager, ThresholdDetector, open_video +from scenedetect.backends import AVAILABLE_BACKENDS +from scenedetect.backends.concat import VideoStreamConcat +from scenedetect.video_stream import VideoOpenFailure + +FADES_TOTAL_FRAMES = 250 +FADES_DURATION = 10.0 + +BACKENDS = [backend for backend in ("opencv", "pyav") if backend in AVAILABLE_BACKENDS] + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_decode_single(test_fades_clip, backend): + """Decode a single video and validate the reported frame count and position.""" + video = VideoStreamConcat([test_fades_clip], backend=backend) + while video.read(decode=False) is not False: + pass + assert video.frame_number == FADES_TOTAL_FRAMES + assert video.decode_failures == 0 + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_decode_multiple(test_fades_clip, backend): + """Decode multiple videos and validate the reported frame count.""" + splice_amount = 3 + video = VideoStreamConcat([test_fades_clip] * splice_amount, backend=backend) + while video.read(decode=False) is not False: + pass + assert video.frame_number == FADES_TOTAL_FRAMES * splice_amount + assert video.decode_failures == 0 + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_seam_monotonicity(test_fades_clip, backend): + """Position must be strictly increasing across the file seam.""" + video = VideoStreamConcat([test_fades_clip] * 2, backend=backend) + last_seconds = -1.0 + max_delta = 0.0 + while video.read(decode=False) is not False: + seconds = video.position.seconds + assert seconds > last_seconds, f"position went backwards: {seconds} <= {last_seconds}" + if last_seconds >= 0: + max_delta = max(max_delta, seconds - last_seconds) + last_seconds = seconds + # The seam should be continuous: no gap larger than a few frame durations. + assert max_delta < 0.5, f"discontinuity across seam: {max_delta}s" + assert last_seconds > 2 * FADES_DURATION - 1.0 + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_seek(test_fades_clip, backend): + """Seeking should work on the global timeline, in either direction, across sources.""" + video = VideoStreamConcat([test_fades_clip] * 2, backend=backend) + # Seek into the second source. + target = FADES_DURATION + 5.0 + video.seek(target) + assert video.read(decode=False) is not False + assert abs(video.position.seconds - target) < 0.25 + # Seek backwards into the first source. + video.seek(5.0) + assert video.read(decode=False) is not False + assert abs(video.position.seconds - 5.0) < 0.25 + + +def test_seek_backward_then_cross_seam(test_fades_clip): + """Crossing the seam a second time after a backward seek must not shift the timeline + again (offset correction must be idempotent).""" + video = VideoStreamConcat([test_fades_clip] * 2) + # Read across the seam once. + video.seek(FADES_DURATION - 0.5) + while video.position.seconds < FADES_DURATION + 0.5: + assert video.read(decode=False) is not False + first_pass = video.position.seconds + # Seek backward before the seam and cross it again. + video.seek(FADES_DURATION - 0.5) + last = video.position.seconds + while video.position.seconds < FADES_DURATION + 0.5: + assert video.read(decode=False) is not False + assert video.position.seconds > last + last = video.position.seconds + assert abs(video.position.seconds - first_pass) < 0.25 + + +def test_seam_monotonicity_vfr(test_vfr_drop3_video): + """Position must also be strictly increasing across the seam between variable framerate + inputs, whose declared duration is less exact than CFR.""" + video = VideoStreamConcat([test_vfr_drop3_video] * 2) + last_seconds = -1.0 + while video.read(decode=False) is not False: + seconds = video.position.seconds + assert seconds > last_seconds, f"position went backwards: {seconds} <= {last_seconds}" + last_seconds = seconds + + +def test_map_span(test_fades_clip): + """A span crossing the seam between two inputs must map to two local spans.""" + video = VideoStreamConcat([test_fades_clip] * 2) + duration = FADES_DURATION + start = video.base_timecode + (duration - 3.0) + end = video.base_timecode + (duration + 3.0) + spans = video.map_span(start, end) + assert len(spans) == 2 + assert spans[0].source_index == 0 and spans[1].source_index == 1 + assert abs(spans[0].local_start.seconds - (duration - 3.0)) < 0.01 + assert abs(spans[0].local_end.seconds - duration) < 0.01 + assert spans[1].local_start.seconds == 0.0 + assert abs(spans[1].local_end.seconds - 3.0) < 0.01 + # A span entirely within the first source maps to a single span. + spans = video.map_span(video.base_timecode + 1.0, video.base_timecode + 2.0) + assert len(spans) == 1 and spans[0].source_index == 0 + + +def test_mismatched_resolution(test_fades_clip, test_video_file): + """Sources with different resolutions cannot be concatenated.""" + with pytest.raises(VideoOpenFailure): + VideoStreamConcat([test_fades_clip, test_video_file]) + + +def test_unknown_backend_falls_back(test_fades_clip): + """An unknown backend name falls back to OpenCV instead of failing.""" + video = VideoStreamConcat([test_fades_clip], backend="not_a_backend") + assert video.child_backend == "opencv" + assert video.read(decode=False) is not False + + +def test_open_video_list(test_fades_clip): + """`open_video` accepts a list of paths and returns a concatenated stream.""" + video = open_video([test_fades_clip, test_fades_clip]) + assert isinstance(video, VideoStreamConcat) + assert video.duration.seconds == pytest.approx(2 * FADES_DURATION, abs=0.1) + # A single-element list also returns a concatenated stream. + video = open_video([test_fades_clip]) + assert isinstance(video, VideoStreamConcat) + + +def test_scene_manager_detect(test_fades_clip): + """The concatenated stream must work end-to-end with SceneManager: detecting fades over + two spliced copies must find twice as many scenes as a single copy.""" + + def detect_scenes(paths): + scene_manager = SceneManager() + scene_manager.add_detector(ThresholdDetector()) + video = open_video(paths) + scene_manager.detect_scenes(video=video) + return scene_manager.get_scene_list() + + single = detect_scenes([test_fades_clip]) + double = detect_scenes([test_fades_clip] * 2) + assert len(single) > 0 + assert len(double) == 2 * len(single) + + +@pytest.mark.skipif("pyav" not in AVAILABLE_BACKENDS, reason="PyAV backend not available") +def test_corrupt_concat(corrupt_video_file): + """The PyAV input path must tolerate corrupt frames and decode the full stream.""" + video = VideoStreamConcat([corrupt_video_file], backend="pyav") + num_frames = 0 + while video.read(decode=False) is not False: + num_frames += 1 + assert num_frames >= 590 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 445112ee..a6a9f283 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. # @@ -17,7 +17,6 @@ """ import os -import typing as ty from dataclasses import dataclass import pytest @@ -32,14 +31,16 @@ ThresholdDetector, ) -FAST_CUT_DETECTORS: ty.Tuple[ty.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: ty.Tuple[ty.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 @@ -57,14 +58,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 +81,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): @@ -97,7 +97,8 @@ def detect(self): def get_fast_cut_test_cases(): """Fixture for parameterized test cases that detect fast cuts.""" test_cases = [] - # goldeneye.mp4 with min_scene_len = 15 (default) + # goldeneye.mp4 with min_scene_len = 15 (default). HistogramDetector's recalibrated defaults + # (threshold=0.20, bins=128) are less sensitive and do not trigger on the cut at frame 1260. test_cases += [ pytest.param( TestCase( @@ -105,9 +106,13 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=15), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], + scene_boundaries=( + [1199, 1226, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [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 ] @@ -119,9 +124,13 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=30), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365], + scene_boundaries=( + [1199, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [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 ] @@ -139,7 +148,7 @@ def get_fade_in_out_test_cases(): detector=ThresholdDetector(), start_time=0, end_time=500, - scene_boundaries=[0, 15, 198, 376], + scene_boundaries=[0, 15, 198, 377], ), id="threshold_testvideo_default", ), @@ -177,7 +186,7 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 42, 125, 209], + scene_boundaries=[0, 42, 126, 209], ), id="threshold_fades_ceil", ), @@ -226,10 +235,29 @@ def test_detectors_with_stats(test_video_file): assert len(scene_list) == initial_scene_len -# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. -def test_deprecated_detector_module_emits_warning_on_import(): - SCENE_DETECTOR_WARNING = ( - "The `scene_detector` submodule is deprecated, import from the base package instead." +@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, + # HistogramDetector's recalibrated defaults do not trigger on the cut at frame 1260 + # (see `get_fast_cut_test_cases`). + scene_boundaries=( + [1199, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [1199, 1260, 1334, 1365] + ), ) - with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): - from scenedetect.scene_detector import SceneDetector as _ + 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_fan_out.py b/tests/test_fan_out.py new file mode 100644 index 00000000..e2286a92 --- /dev/null +++ b/tests/test_fan_out.py @@ -0,0 +1,236 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tests for scenedetect._fan_out.FanOutVideoStream.""" + +from __future__ import annotations + +import threading + +import numpy as np +import pytest + +from scenedetect import ContentDetector, SceneManager, detect, open_video +from scenedetect._fan_out import FanOutVideoStream +from scenedetect.video_stream import SeekError + + +def _read_all(stream) -> list[np.ndarray]: + frames = [] + while True: + frame = stream.read() + if frame is False: + break + frames.append(frame) + return frames + + +def test_fan_out_n1_matches_single_consumer(test_video_file): + """A single consumer behind the wrapper sees the same frames as a bare source.""" + baseline = _read_all(open_video(test_video_file)) + + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=1) + fan.start() + try: + fanout = _read_all(fan.stream(0)) + finally: + fan.close() + + assert len(fanout) == len(baseline) + for a, b in zip(fanout, baseline, strict=True): + assert np.array_equal(a, b) + + +def test_fan_out_frame_equality_across_consumers(test_video_file): + """All N consumers see identical frames in identical order.""" + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=4, prefetch=4) + fan.start() + results: list[list[np.ndarray]] = [[] for _ in range(4)] + + def worker(i: int) -> None: + results[i] = _read_all(fan.stream(i)) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)] + try: + for t in threads: + t.start() + for t in threads: + t.join() + finally: + fan.close() + + counts = {len(r) for r in results} + assert len(counts) == 1, f"Consumers saw different frame counts: {counts}" + n_frames = counts.pop() + assert n_frames > 0 + # Compare frame-by-frame across all consumers. + for k in range(n_frames): + ref = results[0][k] + for i in range(1, 4): + assert np.array_equal(results[i][k], ref), f"frame {k} differs in consumer {i}" + + +def test_fan_out_per_consumer_position(test_video_file): + """Each consumer's frame_number/position advances based on its own reads.""" + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=2, prefetch=4) + fan.start() + try: + s0 = fan.stream(0) + s1 = fan.stream(1) + assert s0.frame_number == 0 + assert s1.frame_number == 0 + # Read 5 frames on s0 (s1 must also keep up because of back-pressure, but its + # frame_number is independent of how many we've consumed there). + for _ in range(5): + assert isinstance(s0.read(), np.ndarray) + assert s0.frame_number == 5 + assert s1.frame_number == 0 # never read; counter is per-consumer + # Now drain s1; it should still see frame 1 first. + for _ in range(5): + assert isinstance(s1.read(), np.ndarray) + assert s1.frame_number == 5 + finally: + fan.close() + + +def test_fan_out_seek_and_reset_raise(test_video_file): + """Consumers are forward-only.""" + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=1) + fan.start() + try: + s = fan.stream(0) + with pytest.raises(SeekError): + s.seek(0) + with pytest.raises(SeekError): + s.reset() + finally: + fan.close() + + +def test_fan_out_eof_returns_false_on_subsequent_reads(test_video_file): + """After end-of-stream, read() keeps returning False (matches VideoStream protocol).""" + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=1) + fan.start() + try: + s = fan.stream(0) + # Drain. + while s.read() is not False: + pass + # Subsequent reads must continue to return False, not block. + assert s.read() is False + assert s.read() is False + finally: + fan.close() + + +def test_fan_out_metadata_forwarded(test_video_file): + """Consumer's frame_rate / frame_size / duration / path match the source.""" + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=2) + fan.start() + try: + for i in range(2): + s = fan.stream(i) + assert s.frame_rate == source.frame_rate + assert s.frame_size == source.frame_size + assert s.duration == source.duration + assert s.path == source.path + assert s.name == source.name + assert s.is_seekable is False + finally: + fan.close() + + +def test_fan_out_cut_list_matches_direct_detect(test_video_file): + """Cut list from SceneManager+FanOut(n=1) matches the production detect() helper. + + Catches any subtle protocol-conformance bug in the consumer side that would + affect detector output. + """ + baseline_scenes = detect(test_video_file, ContentDetector()) + baseline_cuts = [scene[1].frame_num for scene in baseline_scenes] + + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=1) + fan.start() + try: + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=fan.stream(0)) + cuts = [scene[1].frame_num for scene in sm.get_scene_list()] + finally: + fan.close() + + assert cuts == baseline_cuts + + +def test_fan_out_parallel_detection_matches_baseline(test_video_file): + """Two detectors run in parallel from one decode produce the same cut lists as + two independent detect() calls.""" + cd_default = ContentDetector() + cd_loose = ContentDetector(threshold=15.0) + baseline_default = detect(test_video_file, ContentDetector()) + baseline_loose = detect(test_video_file, ContentDetector(threshold=15.0)) + # Use fresh detector instances inside the fan-out (cd_default/cd_loose above were used). + del cd_default, cd_loose + + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=2, prefetch=4) + fan.start() + results: list[list[int]] = [[], []] + + def worker(i: int, det) -> None: + sm = SceneManager() + sm.add_detector(det) + sm.detect_scenes(video=fan.stream(i)) + results[i] = [scene[1].frame_num for scene in sm.get_scene_list()] + + detectors = [ContentDetector(), ContentDetector(threshold=15.0)] + threads = [threading.Thread(target=worker, args=(i, detectors[i])) for i in range(2)] + try: + for t in threads: + t.start() + for t in threads: + t.join() + finally: + fan.close() + + assert results[0] == [scene[1].frame_num for scene in baseline_default] + assert results[1] == [scene[1].frame_num for scene in baseline_loose] + + +def test_fan_out_prefetch_zero_rendezvous(test_video_file): + """prefetch=0 still produces correct frames (uses maxsize=1 internally).""" + source = open_video(test_video_file) + fan = FanOutVideoStream(source, n=2, prefetch=0) + fan.start() + results: list[int] = [0, 0] + + def worker(i: int) -> None: + s = fan.stream(i) + while s.read() is not False: + results[i] += 1 + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(2)] + try: + for t in threads: + t.start() + for t in threads: + t.join() + finally: + fan.close() + + assert results[0] == results[1] > 0 diff --git a/tests/test_output.py b/tests/test_output.py index bc1762e5..8d1b4d9f 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,13 +5,16 @@ # [ 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. # """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 = ( @@ -161,6 +168,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.""" @@ -193,10 +232,286 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) -# TODO(v0.8): Remove this test during the removal of `scenedetect.video_splitter`. -def test_deprecated_output_modules_emits_warning_on_import(): - VIDEO_SPLITTER_WARNING = ( - "The `video_splitter` submodule is deprecated, import from the base package instead." +# +# 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_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)]) + 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-10us 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, ) - with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): - from scenedetect.video_splitter import split_video_ffmpeg as _ + 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-10us float drift" diff --git a/tests/test_platform.py b/tests/test_platform.py index 319a54ea..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. # @@ -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 b1e144d2..b5388e7a 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -15,14 +15,12 @@ which applies SceneDetector algorithms on VideoStream backends. """ -import typing as ty - import pytest from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.common import FrameTimecode from scenedetect.detectors import AdaptiveDetector, ContentDetector -from scenedetect.scene_manager import SceneManager +from scenedetect.scene_manager import SceneManager, expand_scenes_to_bounds TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] @@ -89,7 +87,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..""" @@ -200,15 +198,66 @@ 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) + + +def test_expand_scenes_to_bounds_two_scenes(): + """Scenes detected inside a sub-window should be extended outward.""" + fps = 10.0 + t0 = FrameTimecode(0, fps) + t130 = FrameTimecode(130, fps) + t150 = FrameTimecode(150, fps) + t170 = FrameTimecode(170, fps) + t300 = FrameTimecode(300, fps) + + scenes = [(t130, t150), (t150, t170)] + expanded = expand_scenes_to_bounds(scenes, start=t0, end=t300) + + assert expanded == [(t0, t150), (t150, t300)] + + +def test_expand_scenes_to_bounds_empty(): + """Empty scene lists pass through unchanged.""" + fps = 10.0 + assert expand_scenes_to_bounds([], FrameTimecode(0, fps), FrameTimecode(100, fps)) == [] + + +def test_expand_scenes_to_bounds_single_scene(): + """A single scene gets both endpoints extended.""" + fps = 10.0 + t0 = FrameTimecode(0, fps) + t130 = FrameTimecode(130, fps) + t170 = FrameTimecode(170, fps) + t300 = FrameTimecode(300, fps) + + scenes = [(t130, t170)] + expanded = expand_scenes_to_bounds(scenes, start=t0, end=t300) + + assert expanded == [(t0, t300)] + + +def test_expand_scenes_to_bounds_does_not_mutate_input(): + """The input scene list must not be modified in place.""" + fps = 10.0 + t0 = FrameTimecode(0, fps) + t130 = FrameTimecode(130, fps) + t150 = FrameTimecode(150, fps) + t170 = FrameTimecode(170, fps) + t300 = FrameTimecode(300, fps) + + scenes = [(t130, t150), (t150, t170)] + original = list(scenes) + expand_scenes_to_bounds(scenes, start=t0, end=t300) + + assert scenes == original diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 0c32371e..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. # @@ -27,8 +27,6 @@ """ import csv -import os -import random from pathlib import Path import pytest diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 4d6c0d21..66017bd9 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. # @@ -26,18 +26,21 @@ import pytest # Standard Library Imports -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction def test_framerate(): """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) @@ -64,6 +67,68 @@ def test_framerate(): assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 +def test_frame_rate_property(): + """`frame_rate` returns an exact Fraction; `framerate` returns the float equivalent.""" + # Integer rate. + tc = FrameTimecode(timecode=0, fps=30.0) + assert tc.frame_rate == Fraction(30, 1) + assert isinstance(tc.frame_rate, Fraction) + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy_frame_rate = tc.framerate + + assert legacy_frame_rate == 30.0 + assert isinstance(legacy_frame_rate, float) + # Constructed directly from a Fraction (the exact form for NTSC rates). + tc = FrameTimecode(timecode=0, fps=Fraction(30000, 1001)) + assert tc.frame_rate == Fraction(30000, 1001) + with pytest.warns(DeprecationWarning, match="frame_rate"): + assert tc.framerate == pytest.approx(float(Fraction(30000, 1001))) + tc = FrameTimecode(timecode=0, fps=Fraction(24000, 1001)) + assert tc.frame_rate == Fraction(24000, 1001) + # time_base equals 1 / frame_rate for CFR sources. + assert tc.frame_rate is not None + assert tc.time_base == 1 / tc.frame_rate + + +def test_frame_rate_for_vfr(): + """For Timecode-backed instances, frame_rate is the approximation passed via fps.""" + fps = Fraction(24000, 1001) + tc = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # frame_rate exposes the rate carried by the FrameTimecode (an approximation for VFR). + assert tc.frame_rate == fps + # time_base is authoritative for VFR and need not equal 1 / frame_rate. + assert tc.time_base == Fraction(1, 24000) + assert tc.frame_rate is not None + assert tc.time_base != 1 / tc.frame_rate + + +def test_frame_num_and_frame_rate_are_read_only(): + """Per migration guide, `frame_num`, `frame_rate`, and the legacy `framerate` alias are + read-only properties; callers must construct a new FrameTimecode to change them.""" + tc = FrameTimecode(timecode=0, fps=30.0) + for attr in ("frame_num", "frame_rate", "framerate"): + with pytest.raises(AttributeError): + setattr(tc, attr, 99) + + +def test_equal_frame_rate_legacy_alias(): + """`equal_framerate()` is the deprecated alias for `equal_frame_rate()` (issue #548). + Both forms should produce identical results for every accepted operand type.""" + tc = FrameTimecode(timecode=0, fps=30.0) + # float, Fraction, FrameTimecode operands. + other_tc = FrameTimecode(timecode=0, fps=30.0) + for other in (30.0, Fraction(30, 1), other_tc): + expected = tc.equal_frame_rate(other) + with pytest.warns(DeprecationWarning, match="equal_frame_rate"): + actual = tc.equal_framerate(other) + assert actual == expected + assert actual is True + # Mismatched rate. + assert tc.equal_frame_rate(24.0) is False + with pytest.warns(DeprecationWarning, match="equal_frame_rate"): + assert tc.equal_framerate(24.0) is False + + def test_timecode_numeric(): """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" with pytest.raises(ValueError): @@ -159,7 +224,11 @@ def test_get_frames(): assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == int(1.0 / 1.0) assert FrameTimecode(timecode=1000.0, fps=60.0).frame_num == int(1000.0 * 60.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == int(1000000000.0 * 29.97) + # 29.97 snaps to exact NTSC Fraction(30000, 1001), so expected is computed from that + # rational rather than the lossy float multiplication. + assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == round( + 1000000000.0 * 30000 / 1001 + ) assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2 assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5 @@ -187,7 +256,9 @@ def test_get_timecode(): """Test FrameTimecode get_timecode() method.""" assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == "00:00:01.000" assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117" - assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234" + # 29.97 snaps to exact NTSC Fraction(30000, 1001); 3600.234s lands on the nearest + # NTSC frame at ~01:00:00.230 rather than the lossy-float result of "01:00:00.234". + assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.230" assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).get_timecode() == "00:00:02.000" assert FrameTimecode(timecode="00:00:00.5", fps=10.0).get_timecode() == "00:00:00.500" @@ -299,10 +370,208 @@ def test_precision(): assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" -# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. -def test_deprecated_timecode_module_emits_warning_on_import(): - FRAME_TIMECODE_WARNING = ( - "The `frame_timecode` submodule is deprecated, import from the base package instead." +def test_rational_framerate_precision(): + """Rational framerates should round-trip frame/second conversions without drift.""" + fps = Fraction(24000, 1001) + # Verify that frame_num round-trips through seconds without drift over many frames. + for frame in [0, 1, 100, 1000, 10000, 100000]: + tc = FrameTimecode(frame, fps) + assert tc.frame_num == frame, f"Frame {frame} drifted to {tc.frame_num}" + + +def test_ntsc_framerate_detection(): + """Common NTSC framerates should be detected from float values.""" + assert framerate_to_fraction(23.976023976023978) == Fraction(24000, 1001) + assert framerate_to_fraction(29.97002997002997) == Fraction(30000, 1001) + assert framerate_to_fraction(59.94005994005994) == Fraction(60000, 1001) + assert framerate_to_fraction(119.88011988011988) == Fraction(120000, 1001) + assert framerate_to_fraction(24.0) == Fraction(24, 1) + assert framerate_to_fraction(30.0) == Fraction(30, 1) + assert framerate_to_fraction(60.0) == Fraction(60, 1) + assert framerate_to_fraction(25.0) == Fraction(25, 1) + + +def test_frame_timecode_converts_ntsc_float_fps(): + """End-to-end: passing a float NTSC rate into the FrameTimecode constructor must yield + the exact Fraction representation, not the lossy float. This is the user-facing entry + point most users hit (e.g. when a backend hands them `cap.get(CAP_PROP_FPS)`).""" + expected = { + 23.976: Fraction(24000, 1001), + 29.97: Fraction(30000, 1001), + 59.94: Fraction(60000, 1001), + } + for fps_float, fps_exact in expected.items(): + tc = FrameTimecode(0, fps_float) + assert tc.frame_rate == fps_exact, ( + f"FrameTimecode(0, {fps_float}) produced {tc.frame_rate}, expected {fps_exact}" + ) + assert isinstance(tc.frame_rate, Fraction) + + +def test_ntsc_framerate_detection_arbitrary_base(): + """NTSC detection should work for any base rate, not a hardcoded list (e.g. 48000/1001 + for HFR cinema).""" + assert framerate_to_fraction(47.952047952047955) == Fraction(48000, 1001) + assert framerate_to_fraction(239.76023976023975) == Fraction(240000, 1001) + + +def test_ntsc_framerate_detection_low_precision(): + """Low-precision float reports (e.g. truncated to 3 decimals) should still snap to the + NTSC rational.""" + assert framerate_to_fraction(23.976) == Fraction(24000, 1001) + assert framerate_to_fraction(29.97) == Fraction(30000, 1001) + + +def test_framerate_to_fraction_non_ntsc_fallback(): + """Non-NTSC, non-integer framerates should fall back to limit_denominator and not be + misclassified as NTSC.""" + # 24.5 is not near any N*1000/1001 within tolerance, so the limit_denominator path runs. + assert framerate_to_fraction(24.5) == Fraction(49, 2) + + +def test_timecode_arithmetic_mixed_time_base(): + """Arithmetic with FrameTimecodes using different time_bases should work.""" + fps = Fraction(24000, 1001) + # Timecode with time_base 1/24000 (from PyAV) + tc_pyav = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # Timecode with time_base 1/1000000 (from OpenCV microseconds) + tc_cv2 = FrameTimecode(timecode=Timecode(pts=41708, time_base=Fraction(1, 1000000)), fps=fps) + # Both represent approximately 1 frame duration. Addition/subtraction shouldn't raise. + result = tc_pyav + tc_cv2 + assert result.seconds > 0 + result = tc_pyav - tc_cv2 + assert result.seconds >= 0 # Clamped to 0 if negative + + +def test_timecode_frame_num_for_vfr(): + """frame_num should return approximate values for Timecode-backed objects without warning.""" + fps = Fraction(24000, 1001) + tc = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # Should not raise or warn - just return the approximate frame number. + assert tc.frame_num == 1 + + +def test_arithmetic_with_bare_timecode(): + """`FrameTimecode` arithmetic should accept a bare :class:`Timecode` operand by treating + it as an absolute time in seconds.""" + fps = 30.0 + base = FrameTimecode(timecode=10, fps=fps) # 10 frames @ 30fps == ~0.333s + # 1/30s expressed in a 1/1000 time base is pts=33 (rounded). + one_frame_at_30 = Timecode(pts=33, time_base=Fraction(1, 1000)) + + plus = base + one_frame_at_30 + assert plus.frame_num == 11 + + minus = base - one_frame_at_30 + assert minus.frame_num == 9 + + # Reverse direction: a Timecode-backed FrameTimecode plus a bare Timecode. + pts_base = FrameTimecode(timecode=Timecode(pts=1, time_base=Fraction(1, 1000)), fps=fps) + pts_plus = pts_base + Timecode(pts=2, time_base=Fraction(1, 1000)) + assert pts_plus.seconds == pytest.approx(0.003) + + +def test_comparisons_with_bare_timecode(): + """`FrameTimecode` comparison operators should accept a bare :class:`Timecode` operand.""" + fps = 30.0 + half_second_frame = FrameTimecode(timecode=15, fps=fps) + half_second_tc = Timecode(pts=500, time_base=Fraction(1, 1000)) + one_second_tc = Timecode(pts=1000, time_base=Fraction(1, 1000)) + + assert half_second_frame == half_second_tc + assert half_second_frame != one_second_tc + assert half_second_frame < one_second_tc + assert half_second_frame <= half_second_tc + assert one_second_tc != half_second_frame # reflected via __ne__ + assert FrameTimecode(timecode=30, fps=fps) > half_second_tc + assert FrameTimecode(timecode=15, fps=fps) >= half_second_tc + + +def test_exact_comparison_same_rate(): + """Timecode-backed instances with the same rate compare by exact PTS, not rounded frame + numbers. pts=999 and pts=1001 @ time_base 1/1000 both round to frame 30 @ 30fps, but + represent different presentation times.""" + fps = Fraction(30, 1) + a = FrameTimecode(timecode=Timecode(pts=999, time_base=Fraction(1, 1000)), fps=fps) + b = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 1000)), fps=fps) + assert a.frame_num == b.frame_num == 30 # Rounding collides... + assert a != b # ...but exact times differ. + assert not (a == b) # noqa: SIM201 - deliberately exercises __eq__, not just __ne__. + assert a < b and a <= b + assert b > a and b >= a + assert not (a > b) and not (a >= b) + # Hash may still collide (frame_num-based); that is legal since a != b. + assert hash(a) == hash(b) + # Sets/sorting now distinguish and correctly order the two times. + assert len({a, b}) == 2 + assert sorted([b, a]) == [a, b] + + +def test_exact_equality_across_time_base_representations(): + """Equal exact times expressed in different time bases are equal, and hashes agree.""" + fps = Fraction(30, 1) + a = FrameTimecode(timecode=Timecode(pts=500, time_base=Fraction(1, 1000)), fps=fps) + b = FrameTimecode(timecode=Timecode(pts=1000, time_base=Fraction(1, 2000)), fps=fps) + assert a == b and not (a != b) # noqa: SIM202 - deliberately exercises both operators. + assert a <= b and a >= b + assert not (a < b) and not (a > b) + assert hash(a) == hash(b) + assert len({a, b}) == 1 + + +def test_exact_comparison_requires_same_rate(): + """Timecode-backed instances with DIFFERENT rates keep legacy frame-number comparison.""" + a = FrameTimecode(timecode=Timecode(pts=999, time_base=Fraction(1, 1000)), fps=30.0) + b = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 1000)), fps=30.0) + c = FrameTimecode(b, fps=Fraction(30000, 1001)) # Same time as b, different rate. + # Cross-rate falls back to frame_num comparison: + assert (a == c) == (a.frame_num == c.frame_num) + # Same-rate pair still compares exactly: + assert a != b + + +def test_cross_rate_frame_number_equality_unchanged(): + """Legacy behavior pinned: rated, non-Timecode-backed instances with different rates still + compare by frame number.""" + assert FrameTimecode(timecode=100, fps=25.0) == FrameTimecode(timecode=100, fps=30.0) + + +def test_mixed_representation_comparison_unchanged(): + """Timecode-backed vs frame-backed comparison still uses frame numbers.""" + fps = Fraction(24000, 1001) + vfr = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + assert vfr.frame_num == 1 + assert vfr == FrameTimecode(timecode=1, fps=fps) + + +def test_min_scene_len_accepts_timecode_like(): + """Detector ``min_scene_len`` and FlashFilter ``length`` should accept any TimecodeLike, + including :class:`FrameTimecode` / :class:`Timecode`.""" + from scenedetect.detector import FlashFilter + from scenedetect.detectors import ContentDetector + + # FlashFilter: int, float, str, FrameTimecode, Timecode all valid. + FlashFilter(mode=FlashFilter.Mode.MERGE, length=15) + FlashFilter(mode=FlashFilter.Mode.MERGE, length=0.5) + FlashFilter(mode=FlashFilter.Mode.MERGE, length="00:00:00.500") + FlashFilter(mode=FlashFilter.Mode.MERGE, length=FrameTimecode(timecode=15, fps=30.0)) + FlashFilter( + mode=FlashFilter.Mode.MERGE, + length=Timecode(pts=500, time_base=Fraction(1, 1000)), ) - with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): - from scenedetect.frame_timecode import FrameTimecode as _ + + # ContentDetector: same. + ContentDetector(min_scene_len=FrameTimecode(timecode=15, fps=30.0)) + ContentDetector(min_scene_len=Timecode(pts=500, time_base=Fraction(1, 1000))) + + +def test_get_framerate(): + """`get_framerate()` emits one warning and preserves its legacy float return value.""" + tc = FrameTimecode(timecode=0, fps=30.0) + + with pytest.warns(DeprecationWarning, match="frame_rate") as warning_info: + frame_rate = tc.get_framerate() + + assert len(warning_info) == 1 + assert frame_rate == 30.0 + assert isinstance(frame_rate, float) diff --git a/tests/test_vfr.py b/tests/test_vfr.py new file mode 100644 index 00000000..0a6989e1 --- /dev/null +++ b/tests/test_vfr.py @@ -0,0 +1,437 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tests for VFR (Variable Frame Rate) video support.""" + +import csv +import json +import os + +import cv2 +import numpy as np +import pytest + +from scenedetect import SceneManager, open_video +from scenedetect.common import FrameTimecode, Timecode +from scenedetect.detectors import ContentDetector +from scenedetect.output import save_images, write_scene_list +from scenedetect.stats_manager import StatsManager +from tests.helpers import invoke_cli + +# Expected scene cuts for `goldeneye-vfr.mp4` detected with ContentDetector() and end_time=10.0s. +# Entries are (start_timecode, end_timecode). All backends should agree on cut timecodes since +# CAP_PROP_POS_MSEC gives accurate PTS-derived timestamps. The last scene ends at the clip +# boundary (end_time) which may vary slightly between backends based on frame counting. +EXPECTED_SCENES_VFR: list[tuple[str, str]] = [ + ("00:00:00.000", "00:00:03.921"), + ("00:00:03.921", "00:00:09.676"), +] + +# Expected scene cuts for `goldeneye-vfr-drop3.mp4` - a synthetic VFR clip created from the first +# 10s of goldeneye.mp4 by dropping every 3rd frame (frames 2,5,8,...). PTS durations alternate +# between 1001 and 2002 (time_base=1/24000), nominal fps=24000/1001, avg fps ~= 16. The last scene +# ends at the clip boundary and may vary slightly between backends. +EXPECTED_SCENES_VFR_DROP3: list[tuple[str, str]] = [ + ("00:00:00.000", "00:00:03.754"), + ("00:00:03.754", "00:00:08.759"), +] + + +def _tc_to_secs(tc: str) -> float: + """Parse a HH:MM:SS.mmm timecode string to seconds.""" + h, m, rest = tc.split(":") + s, ms = rest.split(".") + return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000 + + +def test_vfr_position_is_timecode(test_vfr_video: str, auto_close): + """Position should be a Timecode-backed FrameTimecode.""" + video = auto_close(open_video(test_vfr_video, backend="pyav")) + assert video.read() is not False + assert isinstance(video.position._time, Timecode) + + +def test_vfr_position_monotonic_pyav(test_vfr_video: str, auto_close): + """PTS-based position should be monotonically non-decreasing (PyAV).""" + video = auto_close(open_video(test_vfr_video, backend="pyav")) + last_seconds = -1.0 + frame_count = 0 + while True: + frame = video.read() + if frame is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count > 0 + + +def test_vfr_position_monotonic_opencv(test_vfr_video: str, auto_close): + """PTS-based position should be monotonically non-decreasing (OpenCV).""" + video = auto_close(open_video(test_vfr_video, backend="opencv")) + last_seconds = -1.0 + frame_count = 0 + while True: + frame = video.read() + if frame is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count > 0 + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_scene_detection(test_vfr_video: str, backend: str, auto_close): + """Scene detection on VFR video should produce timestamps matching known ground truth. + + Both PyAV (native PTS) and OpenCV (CAP_PROP_POS_MSEC) should agree on scene cuts since + both expose accurate PTS-derived timestamps. + """ + video = auto_close(open_video(test_vfr_video, backend=backend)) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + scene_list = sm.get_scene_list() + + # The last scene ends at the clip boundary which may vary by backend; only check known cuts. + assert len(scene_list) >= len(EXPECTED_SCENES_VFR), ( + f"[{backend}] Expected at least {len(EXPECTED_SCENES_VFR)} scenes, got {len(scene_list)}" + ) + for i, ((start, end), (exp_start_tc, exp_end_tc)) in enumerate( + zip(scene_list, EXPECTED_SCENES_VFR, strict=False) + ): + assert start.get_timecode() == exp_start_tc, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start_tc!r}, got {start.get_timecode()!r}" + ) + assert end.get_timecode() == exp_end_tc, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end_tc!r}, got {end.get_timecode()!r}" + ) + + +def test_vfr_seek_pyav(test_vfr_video: str, auto_close): + """Seeking should work with VFR video.""" + video = auto_close(open_video(test_vfr_video, backend="pyav")) + target_time = 2.0 # seconds + video.seek(target_time) + frame = video.read() + assert frame is not False + # Position should be close to target (within 1 second for keyframe-based seeking). + assert abs(video.position.seconds - target_time) < 1.0 + + +def test_vfr_stats_manager(test_vfr_video: str, auto_close): + """StatsManager should work correctly with VFR video.""" + video = auto_close(open_video(test_vfr_video, backend="pyav")) + stats = StatsManager() + sm = SceneManager(stats_manager=stats) + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + assert len(sm.get_scene_list()) > 0 + + +def test_vfr_csv_output(test_vfr_video: str, tmp_path, auto_close): + """CSV export should work correctly with VFR video.""" + from scenedetect.output import write_scene_list + + video = auto_close(open_video(test_vfr_video, backend="pyav")) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + scene_list = sm.get_scene_list() + assert len(scene_list) > 0 + + csv_path = os.path.join(str(tmp_path), "scenes.csv") + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list) + + # Verify CSV contains valid data. + with open(csv_path) as f: + reader = csv.reader(f) + rows = list(reader) + assert len(rows) >= 3 # 2 header rows + data + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str, auto_close): + """Synthetic VFR video (drop every 3rd frame, alternating 1x/2x durations) should produce + timecodes matching known ground truth with both backends.""" + video = auto_close(open_video(test_vfr_drop3_video, backend=backend)) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, show_progress=False) + scene_list = sm.get_scene_list() + + assert len(scene_list) >= len(EXPECTED_SCENES_VFR_DROP3), ( + f"[{backend}] Expected at least {len(EXPECTED_SCENES_VFR_DROP3)} scenes, got {len(scene_list)}" + ) + for i, ((start, end), (exp_start_tc, exp_end_tc)) in enumerate( + zip(scene_list, EXPECTED_SCENES_VFR_DROP3, strict=False) + ): + assert start.get_timecode() == exp_start_tc, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start_tc!r}, got {start.get_timecode()!r}" + ) + assert end.get_timecode() == exp_end_tc, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end_tc!r}, got {end.get_timecode()!r}" + ) + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str, auto_close): + """PTS-based position should be monotonically non-decreasing on synthetic VFR video.""" + video = auto_close(open_video(test_vfr_drop3_video, backend=backend)) + last_seconds = -1.0 + frame_count = 0 + while True: + if video.read() is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"[{backend}] Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count == 160 # 2/3 of original 240 frames in 10s at 24000/1001 + + +def test_cfr_position_is_timecode(test_movie_clip: str, auto_close): + """CFR video positions should also be Timecode-backed with PTS support.""" + video = auto_close(open_video(test_movie_clip, backend="pyav")) + assert video.read() is not False + assert isinstance(video.position._time, Timecode) + + +def test_cfr_frame_num_exact(test_movie_clip: str, auto_close): + """For CFR video, frame_num should be exact (not approximate).""" + video = auto_close(open_video(test_movie_clip, backend="pyav")) + for expected_frame in range(1, 11): + assert video.read() is not False + assert video.position.frame_num == expected_frame - 1 + + +def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path, auto_close): + """OpenCV save-images thumbnails should match PyAV thumbnails for all scenes. + + If the OpenCV seek off-by-one bug is present, scene thumbnails will show content from the + wrong scene; MSE against PyAV (ground truth) will be very high for those scenes. + """ + # Detect scenes once and save images with both backends from the same scene list. Detection + # must not run per-backend: the cut at 00:01:39.474 scores content_val=27.08 against the + # default threshold of 27.0, so decoder/colorspace differences between backends (or FFmpeg + # builds - e.g. av 17.1.0 on macOS arm64) can flip it, changing the scene count. + video = auto_close(open_video(test_vfr_video, backend="pyav")) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + scene_list = sm.get_scene_list() + assert len(scene_list) > 0 + + # Run save-images for both backends with 1 image per scene for simplicity. The backends + # report different nominal frame rates for VFR video, so rebase the scene list onto each + # video's rate; the underlying PTS values are preserved (FrameTimecode copy constructor). + for backend in ("pyav", "opencv"): + out_dir = tmp_path / backend + out_dir.mkdir() + video = auto_close(open_video(test_vfr_video, backend=backend)) + rebased = [ + (FrameTimecode(start, fps=video.frame_rate), FrameTimecode(end, fps=video.frame_rate)) + for start, end in scene_list + ] + save_images(rebased, video, num_images=1, output_dir=str(out_dir)) + + pyav_imgs = sorted((tmp_path / "pyav").glob("*.jpg")) + opencv_imgs = sorted((tmp_path / "opencv").glob("*.jpg")) + assert len(pyav_imgs) > 0 + assert len(pyav_imgs) == len(opencv_imgs), ( + f"Image count mismatch: pyav={len(pyav_imgs)}, opencv={len(opencv_imgs)}" + ) + + # Compare every corresponding thumbnail. Wrong-scene content produces very high MSE. + MAX_MSE = 5000 + for pyav_path, opencv_path in zip(pyav_imgs, opencv_imgs, strict=False): + img_pyav = cv2.imread(str(pyav_path)) + img_opencv = cv2.imread(str(opencv_path)) + assert img_pyav is not None, f"Failed to load {pyav_path}" + assert img_opencv is not None, f"Failed to load {opencv_path}" + if img_pyav.shape != img_opencv.shape: + # Resize opencv image to match pyav dimensions before comparing. + img_opencv = cv2.resize(img_opencv, (img_pyav.shape[1], img_pyav.shape[0])) + mse = float(np.mean((img_pyav.astype(np.float32) - img_opencv.astype(np.float32)) ** 2)) + assert mse < MAX_MSE, ( + f"Thumbnail mismatch for {pyav_path.name} vs {opencv_path.name}: MSE={mse:.0f}" + ) + + +# ------------------------------------------------------------------ +# Output format tests +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path, auto_close): + """CSV timecodes for VFR video should match known ground truth for both backends.""" + video = auto_close(open_video(test_vfr_video, backend=backend)) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + scene_list = sm.get_scene_list() + assert len(scene_list) >= len(EXPECTED_SCENES_VFR) + + csv_path = tmp_path / "scenes.csv" + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list, include_cut_list=False) + + with open(csv_path) as f: + rows = list(csv.DictReader(f)) + + for i, (row, (exp_start, exp_end)) in enumerate(zip(rows, EXPECTED_SCENES_VFR, strict=False)): + assert row["Start Timecode"] == exp_start, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start!r}, got {row['Start Timecode']!r}" + ) + assert row["End Timecode"] == exp_end, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end!r}, got {row['End Timecode']!r}" + ) + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_otio_export(test_vfr_video: str, backend: str, tmp_path): + """OTIO export for VFR video should have no spurious float precision and correct timecodes. + + Regression test for the float precision bug where seconds * frame_rate could produce + values like 90.00000000000001 instead of 90.0 for CFR video. + """ + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-b", + backend, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-otio", + ] + ) + assert exit_code == 0 + + otio_path = next(tmp_path.glob("*.otio")) + data = json.loads(otio_path.read_text()) + frame_rate = data["global_start_time"]["rate"] + one_frame_secs = 1.0 / frame_rate + + clips = data["tracks"]["children"][0]["children"] + assert len(clips) >= len(EXPECTED_SCENES_VFR) + + for i, (clip, (exp_start_tc, exp_end_tc)) in enumerate( + zip(clips, EXPECTED_SCENES_VFR, strict=False) + ): + sr = clip["source_range"] + start_val = sr["start_time"]["value"] + dur_val = sr["duration"]["value"] + + # No spurious float precision: values should have at most 6 decimal places. + assert round(start_val, 6) == start_val, ( + f"[{backend}] Clip {i + 1} start_time.value has excess precision: {start_val!r}" + ) + assert round(dur_val, 6) == dur_val, ( + f"[{backend}] Clip {i + 1} duration.value has excess precision: {dur_val!r}" + ) + + # Values should round-trip to the expected timecodes within 1 frame. + start_secs = start_val / frame_rate + end_secs = (start_val + dur_val) / frame_rate + assert abs(start_secs - _tc_to_secs(exp_start_tc)) < one_frame_secs, ( + f"[{backend}] Clip {i + 1} start: {start_secs:.4f}s vs expected {exp_start_tc}" + ) + assert abs(end_secs - _tc_to_secs(exp_end_tc)) < one_frame_secs, ( + f"[{backend}] Clip {i + 1} end: {end_secs:.4f}s vs expected {exp_end_tc}" + ) + + +def test_vfr_edl_export(test_vfr_video: str, tmp_path): + """EDL export for VFR video should succeed and contain valid edit entries. + + EDL uses HH:MM:SS:FF frame counts at nominal fps, which is an approximation for VFR + content. This test only verifies structural correctness, not exact timecodes. + """ + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-edl", + ] + ) + assert exit_code == 0 + edl_path = next(tmp_path.glob("*.edl")) + content = edl_path.read_text() + assert "FCM: NON-DROP FRAME" in content + assert "001 AX V" in content + + +@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, auto_close): + """PyAV and OpenCV should produce identical scene timecodes for VFR video. + + Only the known interior scenes are compared; the last scene's end time may vary slightly + between backends since it reflects the clip boundary rather than a detected cut. + """ + timecodes: dict[str, list[tuple[str, str]]] = {} + for backend in ("pyav", "opencv"): + video = auto_close(open_video(test_vfr_video, backend=backend)) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + timecodes[backend] = [(s.get_timecode(), e.get_timecode()) for s, e in sm.get_scene_list()] + # Compare only the known scenes (last scene's end varies by backend at the clip boundary). + n = len(EXPECTED_SCENES_VFR) + assert timecodes["pyav"][:n] == timecodes["opencv"][:n], ( + f"Backend timecode mismatch:\n pyav: {timecodes['pyav']}\n opencv: {timecodes['opencv']}" + ) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 922be83d..856ad1e7 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. # @@ -42,9 +42,9 @@ def get_moviepy_major_version() -> int: - import moviepy + import importlib.metadata - return int(moviepy.__version__.split(".")[0]) + return int(importlib.metadata.version("moviepy").split(".")[0]) def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: @@ -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( @@ -121,20 +120,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), ] @@ -143,11 +139,14 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" - stream = vs_type(test_video.path) + stream = auto_close(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(".") @@ -156,23 +155,30 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate basic `read` functionality.""" - stream = vs_type(test_video.path) + stream = auto_close(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: ty.Type[VideoStream], test_video: VideoParameters): + def test_read_no_decode( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate invoking `read` with `decode` set to False.""" - stream = vs_type(test_video.path) + stream = auto_close(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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate the `frame_number`, `position`, and `position_ms` properties.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # The video starts "before" the first frame, with everything set to zero. assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -193,9 +199,11 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Test `reset()` functions as expected.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Decode some frames, then reset the VideoStream and validate the time invariants. for _ in range(10): stream.read() @@ -205,9 +213,11 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate `seek()` functionality with different offset types.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Seek to a given frame number (int). stream.seek(200) @@ -251,9 +261,11 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate behaviour of `seek()` at the start of a video.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Here we check similar invariants to test_time_invariants, but using seek(). assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -284,11 +296,13 @@ def test_seek_start(self, vs_type: ty.Type[VideoStream], test_video: VideoParame assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) stream.read() assert stream.frame_number == 2 - stream = vs_type(test_video.path) + stream = auto_close(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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Ensure calling `read()` handles the end of the video correctly.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # To make the test faster, we seek to the second last frame. stream.seek(test_video.total_frames - 1) while stream.read() is not False: @@ -299,9 +313,11 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate calling `seek()` to offset past end of video.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit # frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF, # in which case they should raise a SeekError (and the test is considered a pass). @@ -318,9 +334,11 @@ 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Test `seek()` throws correct exception when specifying in invalid seek value.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) with pytest.raises(ValueError): stream.seek(-1) @@ -334,13 +352,27 @@ 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: 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: ty.Type[VideoStream], corrupt_video_file: str): +def test_framerate_legacy_alias(vs_type: ty.Callable[..., VideoStream], auto_close): + """`framerate=` is the deprecated alias for `frame_rate=` (issue #548). All backends + must accept both forms and produce the same `frame_rate`.""" + path = get_absolute_path("resources/goldeneye.mp4") + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy = auto_close(vs_type(path, framerate=30.0)) + canonical = auto_close(vs_type(path, frame_rate=30.0)) + assert legacy.frame_rate == canonical.frame_rate + # When both are provided, `frame_rate` wins (legacy is ignored). + with pytest.warns(DeprecationWarning, match="frame_rate"): + both = auto_close(vs_type(path, frame_rate=30.0, framerate=24.0)) + assert both.frame_rate == canonical.frame_rate + + +def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_file: str, auto_close): """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. @@ -349,9 +381,34 @@ def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): # on certain versions of MoviePy. pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253") - stream = vs_type(corrupt_video_file) + stream = auto_close(vs_type(corrupt_video_file)) + + # The fixture has 596 frames, one of which is corrupt. Depending on the FFmpeg build, the bad + # frame is either skipped (incrementing `decode_failures`) or concealed and decoded anyway. + # Either way the backend must decode the rest of the stream without raising. + frames_read = 0 + while stream.read(decode=False) is not False: + frames_read += 1 + assert frames_read >= 590, f"Only decoded {frames_read} frames!" + assert isinstance(stream.decode_failures, int) + assert stream.decode_failures >= 0 - # 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 + +def test_decode_failures_clean_video(vs_type: ty.Callable[..., VideoStream], auto_close): + """`decode_failures` must exist on every backend and stay 0 on a clean video.""" + stream = auto_close(vs_type(get_absolute_path("resources/testvideo.mp4"))) + assert stream.decode_failures == 0 + for _ in range(10): + assert stream.read() is not False + assert stream.decode_failures == 0 + + +def test_delayed_start_normalized( + vs_type: ty.Callable[..., VideoStream], delayed_start_video: str, auto_close +): + """Files with a nonzero stream start time must report the first frame at t=0 on every + backend (the fixture has a start time of 1.075s).""" + stream = auto_close(vs_type(delayed_start_video)) + assert stream.read(decode=False) is not False + assert stream.position.seconds < 0.1 + assert stream.frame_number == 1 diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 71ed0b0b..3c97a978 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,14 +8,14 @@ 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 favicon: img/favicon.ico custom_dir: overrides -# TODO: deprecated option for this theme -google_analytics: ['UA-72551323-1', 'auto'] +# Google Analytics is injected manually via overrides/main.html (the top-level +# `google_analytics` option was deprecated and removed from MkDocs). nav: - 'PySceneDetect': @@ -27,6 +27,7 @@ nav: - 'Documentation': 'docs.md' - 'Command-Line': 'cli.md' - 'Python API': 'api.md' + - 'Benchmarks': 'benchmarks.md' - 'Support:': - 'FAQ': 'faq.md' - 'Bugs & Contributing': 'contributing.md' @@ -40,3 +41,6 @@ markdown_extensions: [fenced_code] extra_css: - style.css + +extra_javascript: + - js/helper.js diff --git a/website/overrides/main.html b/website/overrides/main.html index 0ab35242..f1413372 100644 --- a/website/overrides/main.html +++ b/website/overrides/main.html @@ -9,3 +9,14 @@ {% endblock %} + +{% block extrahead %} + +{% endblock %} diff --git a/website/pages/api.md b/website/pages/api.md index c6513693..009a6aac 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -15,7 +15,7 @@ Internally, this detector functions by converting the colorspace of each decoded `detect-content` also has edge detection, which can be enabled by providing a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). Changes in edges are typically larger than the other components, so threshold may need to be increased accordingly. For example, `-w 1.0 0.5 1.0 0.2 -t 32` is a good starting point to use with edge detection. The default weights are `--weights 1.0 1.0 1.0 0.0` which does not include edges, but this may change in the future. -See [the documentation for detect-content](http://scenedetect.com/projects/Manual/en/latest/cli/detectors.html#detect-content) for details. +See [the documentation for detect-content](https://www.scenedetect.com/docs/latest/cli/detectors.html#detect-content) for details. ## Adaptive Content Detector @@ -27,7 +27,7 @@ The threshold-based scene detector (`detect-threshold`) is how most traditional ## Histogram Detector -The scene change detection algorithm uses histograms of the Y channel in the YCbCr color space to detect scene changes, which helps mitigate issues caused by lighting variations. Each frame of the video is converted from its original color space to the YCbCr color space.The Y channel, which represents luminance, is extracted from the YCbCr color space. This helps in focusing on intensity variations rather than color variations. A histogram of the Y channel is computed using the specified number of bins (--bins/-b). The histogram is normalized to ensure that it can be consistently compared with histograms from other frames. The normalized histogram of the current frame is compared with the normalized histogram of the previous frame using the correlation method (cv2.HISTCMP_CORREL). A scene change is detected if the correlation between the histograms of consecutive frames is below the specified threshold (--threshold/-t). This indicates a significant change in luminance, suggesting a scene change. +The scene change detection algorithm uses histograms of the Y channel in the YCbCr color space to detect scene changes, which helps mitigate issues caused by lighting variations. Each frame of the video is converted from its original color space to the YCbCr color space. The Y channel, which represents luminance, is extracted from the YCbCr color space. This helps in focusing on intensity variations rather than color variations. A histogram of the Y channel is computed using the specified number of bins (--bins/-b). The histogram is normalized to ensure that it can be consistently compared with histograms from other frames. The normalized histogram of the current frame is compared with the normalized histogram of the previous frame using the correlation method (cv2.HISTCMP_CORREL). A scene change is detected if the correlation between the histograms of consecutive frames is below the specified threshold (--threshold/-t). This indicates a significant change in luminance, suggesting a scene change. ## Perceptual Hash Detector @@ -36,7 +36,7 @@ The perceptual hash detector (`detect-hash`) calculates a hash for a frame and c # Creating New Detection Algorithms -All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). +All scene detection algorithms must inherit from [the base `SceneDetector` class](https://www.scenedetect.com/docs/latest/api/detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). Creating a new scene detection method can be as simple as implementing the `process_frame` function, and optionally `post_process`: @@ -45,6 +45,7 @@ import typing as ty import numpy as np from scenedetect import FrameTimecode, SceneDetector + class CustomDetector(SceneDetector): """CustomDetector class to implement a scene detection algorithm.""" @@ -63,7 +64,7 @@ class CustomDetector(SceneDetector): `process_frame` is called on every frame in the input video, which will be called after the final frame of the video is passed to `process_frame`. This may be useful for multi-pass algorithms, or detectors which are waiting on some condition but still wish to output an event on the final frame. -For example, a detector may output at most 1 cuts for every call to `process_frame`, it may output the entire scene list in `post_process`, or a combination of both. Note that the latter will not work in cases where a live video stream or camera input device is being used. See the [API documentation for the `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/detector.html#scenedetect.scene_detector.SceneDetector) for details. Alternatively, you can call `help(SceneDetector)` from a Python REPL. For examples of actual detection algorithm implementations, see the source files in the `scenedetect/detectors/` directory (e.g. `threshold_detector.py`, `content_detector.py`). +For example, a detector may output at most 1 cuts for every call to `process_frame`, it may output the entire scene list in `post_process`, or a combination of both. Note that the latter will not work in cases where a live video stream or camera input device is being used. See the [API documentation for the `SceneDetector` class](https://www.scenedetect.com/docs/latest/api/detector.html#scenedetect.scene_detector.SceneDetector) for details. Alternatively, you can call `help(SceneDetector)` from a Python REPL. For examples of actual detection algorithm implementations, see the source files in the `scenedetect/detectors/` directory (e.g. `threshold_detector.py`, `content_detector.py`). Processing is done by calling the `process_frame(...)` function for all frames in the video, followed by `post_process(...)` (optional) after the final frame. Scene cuts are detected and added to the passed list object in both cases. diff --git a/website/pages/benchmarks.md b/website/pages/benchmarks.md new file mode 100644 index 00000000..4abb2417 --- /dev/null +++ b/website/pages/benchmarks.md @@ -0,0 +1,109 @@ + +# Benchmarks + +PySceneDetect's detectors are benchmarked for accuracy against public +shot-boundary-detection corpora. Scoring follows the +[TRECVID-SBD convention](https://www-nlpir.nist.gov/projects/tv2007/pastdata/shot_boundary.07.html) +(greedy 1-to-1 nearest-neighbor matching with a configurable frame tolerance for hard cuts; +point-in-interval matching for fades), so numbers are comparable to published results. +The benchmark harness, datasets, and full raw results live in +[`benchmark/`](https://github.com/Breakthrough/PySceneDetect/tree/main/benchmark) on GitHub. + +Three datasets are used, chosen to cover very different content: + + - **BBC Planet Earth** - 11 long-form broadcast episodes (hard cuts only) + - **AutoShot** - short-form web/user-generated clips (hard cuts only) + - **ClipShots** - 500 short web clips with hard cuts *and* typed gradual transitions + +## Accuracy at default settings + +Grouped bar chart of hard-cut F1 score per detector and dataset at default settings. AdaptiveDetector leads on BBC (92) and AutoShot (74); HistogramDetector trails, dropping to 20 on ClipShots. + +Hard cuts, strict frame-exact matching (tolerance 0). F1 cells are shaded by score. + +### BBC Planet Earth + +
+ + + + + + +
DetectorRecallPrecisionF1
AdaptiveDetector87.1296.5591.59
ContentDetector84.7088.7786.69
HashDetector92.3075.5683.10
HistogramDetector89.8472.0379.96
ThresholdDetector *0.060.700.11
+ +### AutoShot + + + + + + + + +
DetectorRecallPrecisionF1
AdaptiveDetector70.5977.4673.86
ContentDetector63.4976.1969.26
HashDetector56.4876.1164.84
HistogramDetector63.2753.2357.82
ThresholdDetector *0.7538.641.47
+ +### ClipShots (hard cuts) + + + + + + + + +
DetectorRecallPrecisionF1
AdaptiveDetector85.9741.2555.75
ContentDetector81.9342.3655.84
HashDetector81.3430.1443.98
HistogramDetector72.2011.4719.80
ThresholdDetector *0.080.580.14
+ +### ClipShots (fades) + + + + + + + + +
DetectorRecallPrecisionF1
AdaptiveDetector13.6598.1223.96
ContentDetector26.0398.0441.14
HashDetector18.7794.5331.33
HistogramDetector69.6781.9975.33
ThresholdDetector *5.6999.2410.77
+ +\* ThresholdDetector detects fades to/from black, not shot-to-shot transitions; near-zero +hard-cut scores are expected. Included for completeness. + +## Parameter sweeps + +Beyond the default values, a sweep over each detector's key parameters shows how +accuracy per dataset changes: + +Four small-multiple line charts showing hard-cut F1 at 1-frame tolerance versus threshold for detect-content, detect-adaptive, detect-hash, and detect-hist. Each panel has one line per dataset with a dot at that dataset's optimum; BBC and AutoShot peak at lower thresholds than ClipShots in most panels. + +Dots mark each dataset's optimum within the shown parameter slice. Long-form broadcast content (BBC) +generally prefers lower thresholds than short web clips (ClipShots), so the defaults aim for a +robust middle ground. + +Grouped bar chart of hard-cut F1 at 1-frame tolerance after parameter tuning. Bars show the best single cross-dataset parameter set per detector and dataset, a black tick marks the v0.7 default, and a dot marks each dataset's own optimum. HistogramDetector shows the largest gap between default and tuned scores, most dramatically on ClipShots (20 vs 48); for ContentDetector and AdaptiveDetector on BBC the default tick sits slightly above the tuned bar. + +Scored by mean hard-cut F1 at 1-frame tolerance across all three datasets: + + + + + + + +
DetectorBest mean F1Best parametersv0.7 default
AdaptiveDetector76.3adaptive_threshold=3.5, window_width=3, min_scene_len=0.6sadaptive_threshold=3.0, window_width=2
ContentDetector73.4threshold=31, min_scene_len=0.6sthreshold=27
HashDetector69.8threshold=0.35, size=8threshold=0.395, size=16
HistogramDetector66.3threshold=0.20, bins=128threshold=0.05, bins=256
+ +Full per-dataset breakdowns are in +[`benchmark/SWEEP_REPORT.md`](https://github.com/Breakthrough/PySceneDetect/blob/main/benchmark/SWEEP_REPORT.md). + +## Benchmarking + +See [`benchmark/README.md`](https://github.com/Breakthrough/PySceneDetect/blob/main/benchmark/README.md) +for dataset download instructions and usage. + +```bash +# Score one detector on one dataset: +python -m benchmark --detector detect-content --dataset BBC + +# Grid sweep over detector parameters: +python -m benchmark.sweep --detector detect-content --dataset BBC \ + --params "threshold=15:35:1;min_scene_len=0.0:1.0:0.1" +``` diff --git a/website/pages/changelog.md b/website/pages/changelog.md index acfb9ffd..334fb876 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -1,6 +1,145 @@ -Releases -========================================================== +# Releases + +## PySceneDetect 0.7 + +### PySceneDetect 0.7.1 (July 21, 2026) + +PySceneDetect 0.7.1 adds support for concatenating multiple videos, along with several stability and robustness fixes for the PyAV and OpenCV backends. + +#### CLI Changes + + - [feature] `split-video` has a new `--expand` flag: when scenes are detected within a time window (`-s`/`-e`), the first output clip is extended back to the start of the video and the last clip is extended forward to the end, so no footage outside the analysis window is dropped [#115](https://github.com/Breakthrough/PySceneDetect/issues/115) + +#### API Changes + + - [feature] `scenedetect.detect()` now accepts a `backend` keyword argument (`"opencv"`, `"pyav"`, or `"moviepy"`) similar to `open_video`. Defaults to `"opencv"`, matching prior behavior. + - [feature] Add `expand_scenes_to_bounds()` helper in `scenedetect.scene_manager` to extend a scene list so the first scene starts at a given lower bound and the last scene ends at a given upper bound + - [feature] `VideoStream` now provides a public read-only `decode_failures` property reporting the number of frames that failed to decode and were skipped (defaults to 0; populated by the OpenCV and PyAV backends) + - [feature] Add `VideoStreamConcat` (`scenedetect.backends.concat`) which concatenates multiple videos into a single continuous stream with a monotonic PTS timeline; `open_video()` and `detect()` now accept a list of paths. `VideoStreamConcat.map_span()` maps spans of the global timeline back to per-source local times + - [bugfix] The PyAV backend (`VideoStreamAv`) now skips corrupt frames during `read()` and continues decoding instead of failing, giving up only after 8 consecutive decode failures (matching the OpenCV backend's tolerance behavior) + - [bugfix] The PyAV backend now normalizes presentation times by the stream start time, so files with a delayed start (e.g. from edit lists) report the first frame at `position` 0, consistent with other backends and with `seek()` + - [bugfix] Comparisons between two `FrameTimecode` objects that both carry exact presentation times (e.g. positions from VFR videos) and share the same frame rate are now performed exactly using `pts` and `time_base` instead of rounded frame numbers. Previously, distinct frames in VFR sections could compare equal or fail strict ordering when their times rounded to the same approximate frame number. Comparisons involving frame- or seconds-based timecodes, plain values (`int`/`float`/`str`), or differing frame rates are unchanged + - [bugfix] Fix image sequence inputs when using OpenCV 5.0 + +#### Packaging + + - [general] `scenedetect` and `scenedetect-headless` are unchanged: they continue to ship the full program (library + CLI) with `opencv-python` / `opencv-python-headless` respectively. Both packages provide the same `scenedetect` module (install or depend only one) + - [feature] Official Docker images are now published to the GitHub Container Registry with the full CLI, all backends, and external tools (ffmpeg, mkvmerge) included, thanks [@FNGarvin](https://github.com/FNGarvin) [#537](https://github.com/Breakthrough/PySceneDetect/pull/537) + - Example usage (process a video in the current directory): +```bash +docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect -i /files/video.mp4 detect-adaptive split-video -o /files +``` + - [general] The Windows distribution now bundles OpenCV 5.0, PyAV 18, and FFmpeg 8.1.2. The Windows and Docker builds also override Pillow to 12.3.0 for upstream security fixes ([moviepy#2553](https://github.com/Zulko/moviepy/issues/2553)) + +#### General + + - [general] Benchmark results are now published on the website ([scenedetect.com/benchmarks](https://www.scenedetect.com/benchmarks/)), including accuracy at default settings and parameter sweep curves for each detector + +### 0.7 (May 3, 2026) + +PySceneDetect 0.7 is a **major breaking release** which overhauls how timestamps are handled. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. + +Care was taken to minimize changes for most common API uses, however more advanced use cases may run into breaking changes. Please review [the Migration Guide](https://www.scenedetect.com/docs/0.7/api/migration_guide.html) when updating from v0.6. Minimum supported Python version is now **Python 3.10**. + +#### CLI Changes + +- [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters +- [feature] All CLI options which used to accept frame numbers only now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) +- [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] New `save-qp` command writes a QP file with scene boundary frame numbers, suitable for forcing keyframes at scene cuts in x264/x265 [#448](https://github.com/Breakthrough/PySceneDetect/issues/448) +- [feature] New `save-html` command replaces the deprecated `export-html`; the prior command remains as an alias and emits a deprecation warning [#518](https://github.com/Breakthrough/PySceneDetect/issues/518) +- [feature] Add `save-edl` option `--start-timecode`/`-s` to provide a custom start timecode for generated EDLs, supports SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF` input [#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) +- [feature] The MoviePy backend now supports overriding the source frame rate via `-f`/`--frame-rate` (and the `VideoStreamMoviePy(frame_rate=...)` API), bringing it in line with the OpenCV and PyAV backends +- [bugfix] `detect-threshold` cut frame numbers are now backend-deterministic; previously the cut could differ by 1 frame between PyAV and OpenCV when the fade midpoint landed on a `.5` rounding boundary (PyAV uses sub-microsecond PTS, OpenCV uses millisecond-truncated `CAP_PROP_POS_MSEC`) +- [breaking] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command (use `-c`/`--min-content-val` instead) +- [breaking] Rename `-f/--framerate` to `-f/--frame-rate` as part of VFR overhaul (legacy `--framerate` form is preserved as a hidden alias but will be removed in v0.8) +- [general] Support `SCENEDETECT_DEBUG` environment variable to control how exceptions and debugging are handled. Unhandled exceptions and `Ctrl+C` now produce a logger-formatted error message and exit cleanly with code 1 instead of dumping a raw Python traceback. Set `SCENEDETECT_DEBUG=1` to ensure all exceptions are re-raised instead of being logged. In both cases, the program will exit with a non-zero exit code. + +#### API Changes + +**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` + * `VideoStream.frame_rate` now returns `Fraction` instead of `float` + * Framerates are now stored as rational `Fraction` values (e.g. `Fraction(24000, 1001)` instead of `23.976`) to avoid float precision loss + * Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values + * `FrameTimecode.frame_num` is now approximate for VFR video (based on PTS-derived time) + * Add `frame_rate` property (returns exact `Fraction`) as the canonical replacement for `framerate` (returns `float`) in `FrameTimecode` and `VideoStream` + * For CFR sources, both properties represent the same rate, i.e. `time_base` equals `1 / frame_rate` for CFR sources [#548](https://github.com/Breakthrough/PySceneDetect/issues/548) + * Add `frame_rate` keyword argument to `open_video()` and the `VideoStreamCv2`, `VideoCaptureAdapter`, `VideoStreamAv`, and `VideoStreamMoviePy` constructors as the canonical replacement for `framerate` [#548](https://github.com/Breakthrough/PySceneDetect/issues/548); accepts `float | Fraction | None`. The legacy `framerate` keyword is retained as a deprecated alias and is ignored when `frame_rate` is provided + * Add `equal_frame_rate(other)` method as the canonical replacement for `equal_framerate(fps)` + +**General:** + + * Type hints: audit and overhaul: first-party code is now clean with Pyright basic mode, migrated deprecated type hints to comply with PEP 585 + * Code quality: expand static analysis rules, audit and cleanup existing suppressions + * Packaging: modernized to comply with PEP 621, make `opencv-python` a requirement, add separate `scenedetect-headless` variant instead + +**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()` (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 + * 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:** + + * `scenedetect.scene_detector` moved to `scenedetect.detector` + * `scenedetect.frame_timecode` moved to `scenedetect.common` + * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + +**FrameTimecode:** + + * Add properties to access `frame_num`, `frame_rate`, and `seconds` instead of getter methods + * `frame_num` and `frame_rate` are now read-only properties (construct a new `FrameTimecode` to change them) + * Remove `FrameTimecode.previous_frame()` method + * Deprecated functionality preserved from v0.6 now uses the `warnings` module to emit runtime deprecation warnings, these features will be removed in v0.8 + * Soft-deprecate `framerate` property and `equal_framerate()` method via docstring; the legacy forms will continue to work until v0.8 when they will be upgraded to `DeprecationWarning` before removal in v0.9 + +**Removals:** + + * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead + * Remove deprecated parameters `base_timecode` and `video_manager` from various functions + * Remove deprecated `SceneManager.get_event_list()` method + * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) + * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) + * Remove `advance` parameter from `VideoStream.read()` + * Remove `SceneDetector.stats_manager_required` property, no longer required + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) + +#### Windows Distribution + + - [general] Updates to Windows distributions: + - av 14.2.0 -> 17.0.1 + - click 8.1.8 -> 8.2.1 + - imageio-ffmpeg 0.6.0 + - moviepy 2.1.2 -> 2.2.1 + - numpy 2.2.3 -> 2.4.4 + - opencv-python-headless 4.11.0.86 -> 4.13.0.92 + - platformdirs 4.3.6 -> 4.9.6 + - tqdm 4.67.1 -> 4.67.3 + - ffmpeg 8.0 -> 8.1 + - [general] Reduced size of Windows distribution without affecting functionality + - [bugfix] Pressing `Ctrl+C` during scene detection in the bundled distribution now exits cleanly instead of surfacing the PyInstaller bootloader traceback + + +---------------------------------------------------------------- + ## PySceneDetect 0.6 @@ -10,8 +149,6 @@ Re-release of the Python package that fixes dependency version pinning. ### PySceneDetect 0.6.7 (August 24, 2025) -#### Release Notes - Minor update to fix issues with importing EDL files into DaVinci Resolve and other editors. #### Changelog @@ -23,8 +160,6 @@ Minor update to fix issues with importing EDL files into DaVinci Resolve and oth ### PySceneDetect 0.6.6 (March 9, 2025) -#### Release Notes - PySceneDetect v0.6.6 introduces new output formats, which improve compatibility with popular video editors (e.g. DaVinci Resolve). #### Changelog @@ -49,8 +184,6 @@ PySceneDetect v0.6.6 introduces new output formats, which improve compatibility ### PySceneDetect 0.6.5 (November 24, 2024) -#### Release Notes - This release brings crop support, performance improvements to save-images, lots of bugfixes, and improved compatibility with MoviePy 2.0+. #### Changelog @@ -90,8 +223,6 @@ This release brings crop support, performance improvements to save-images, lots ### 0.6.4 (June 10, 2024) -#### Release Notes - Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ash2703), adds flash filter to content detector, and includes various bugfixes. Below shows the scores of the new detectors normalized against `detect-content` for comparison on a difficult segment with 3 cuts: comparison of new detector scores @@ -117,9 +248,7 @@ Feedback on the new detection methods and their default values is most welcome. ### 0.6.3 (March 9, 2024) -#### Release Notes - -In addition to some perfromance improvements with the `load-scenes` command, this release of PySceneDetect includes a significant amount of bugfixes. Thanks to everyone who contributed to the release, including those who filed bug reports and helped with debugging! +In addition to some performance improvements with the `load-scenes` command, this release of PySceneDetect includes a significant amount of bugfixes. Thanks to everyone who contributed to the release, including those who filed bug reports and helped with debugging! **Program Changes:** @@ -161,8 +290,6 @@ In addition to some perfromance improvements with the `load-scenes` command, thi ### 0.6.2 (July 23, 2023) -#### Release Notes - Includes new [`load-scenes` command](https://www.scenedetect.com/docs/0.6.2/cli.html#load-scenes), ability to specify a default detector, PyAV 10 support, and several bugfixes. Minimum supported Python version is now **Python 3.7**. **Command-Line Changes:** @@ -208,8 +335,6 @@ Includes new [`load-scenes` command](https://www.scenedetect.com/docs/0.6.2/cli. ### 0.6.1 (November 28, 2022) -#### Release Notes - Includes [MoviePy support](https://github.com/Zulko/moviepy), edge detection capability for fast cuts, and several enhancements/bugfixes. #### Changelog @@ -258,8 +383,6 @@ Includes [MoviePy support](https://github.com/Zulko/moviepy), edge detection cap ### 0.6 (May 29, 2022) -#### Release Notes - PySceneDetect v0.6 is a **major breaking change** including better performance, configuration file support, and a more ergonomic API. The new **minimum Python version is now 3.6**. See the [Migration Guide](https://scenedetect.com/projects/Manual/en/latest/api/migration_guide.html) for information on how to port existing applications to the new API. Most users will see performance improvements after updating, and changes to the command-line are not expected to break most workflows. The main goals of v0.6 are reliability and performance. To achieve this required several breaking changes. The video input API was refactored, and *many* technical debt items were addressed. This should help the eventual transition to the first planned stable release (v1.0) where the goal is an improved scene detection API. @@ -372,8 +495,6 @@ Both the Windows installer and portable distributions now include signed executa ### 0.5.6 (August 15, 2021) -#### Release Notes - * **New detection algorithm**: `detect-adaptive` which works similar to `detect-content`, but with reduced false negatives during fast camera movement (thanks @scarwire and @wjs018) * Images generated by `save-images` can now be resized via the command line * Statsfiles now work properly with `detect-threshold` @@ -404,8 +525,6 @@ Both the Windows installer and portable distributions now include signed executa ### 0.5.5 (January 17, 2021) -#### Release Notes - * One of the last major updates before transitioning to the new v0.6.x API * The `--min-scene-len`/`-m` option is now global rather than per-detector * There is a new global option `--drop-short-scenes` to go along with `-m` @@ -447,8 +566,6 @@ Both the Windows installer and portable distributions now include signed executa ### 0.5.4 (September 14, 2020) -#### Release Notes - * Improved performance when using `time` and `save-images` commands * Improved performance of `detect-threshold` when using a small minimum percent * Fix crash when using `detect-threshold` with a statsfile @@ -473,8 +590,6 @@ Both the Windows installer and portable distributions now include signed executa ### 0.5.3 (July 12, 2020) -#### Release Notes - * Resolved long-standing bug where `split-video` command would duplicate certain frames at the beginning/end of the output ([#93](https://github.com/Breakthrough/PySceneDetect/issues/93)) * This was determined to be caused by copying (instead of re-encoding) the audio track, causing extra frames to be brought in when the audio samples did not line up on a frame boundary (thank you @joshcoales for your assistance) * Default behavior is to now re-encode audio tracks using the `aac` codec when using `split-video` (it can be overridden in both the command line and Python interface) @@ -665,63 +780,8 @@ Both the Windows installer and portable distributions now include signed executa Development ========================================================== -## PySceneDetect 0.7 (In Development) - -### Release Notes - -PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. - -Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.10**. - -### CLI Changes - -- [feature] [WIP] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) -- [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command - -### API Changes - -**VFR & Timestamp Overhaul:** - - * Add new `Timecode` type to represent frame timings in terms of the video's source timebase - * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information - * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` - * `VideoStream.frame_rate` now returns `Fraction` instead of `float` - * Framerates are now stored as rational `Fraction` values (e.g. `Fraction(24000, 1001)` instead of `23.976`) to avoid float precision loss - * Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values - * `FrameTimecode.frame_num` is now approximate for VFR video (based on PTS-derived time) - -**Detector Interface:** - - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface [#168](https://github.com/Breakthrough/PySceneDetect/issues/168): - * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` - * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` - * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) - * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called - * Remove `SceneDetector.is_processing_required()` method - * Remove `SceneDetector.stats_manager_required` property, no longer required - * Remove deprecated `SparseSceneDetector` interface - -**Module Reorganization:** - - * `scenedetect.scene_detector` moved to `scenedetect.detector` - * `scenedetect.frame_timecode` moved to `scenedetect.common` - * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) - * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) - -**FrameTimecode:** - - * `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them - * Add properties to access `frame_num`, `framerate`, and `seconds` instead of getter methods - * Remove `FrameTimecode.previous_frame()` method - * Deprecated functionality preserved from v0.6 now uses the `warnings` module - -**Removals:** - - * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead - * Remove deprecated parameters `base_timecode` and `video_manager` from various functions - * Remove deprecated `SceneManager.get_event_list()` method - * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) - * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) - * Remove `advance` parameter from `VideoStream.read()` - +## PySceneDetect 0.7.2 (TBD) + - [general] The `scenedetect-core` package introduced in 0.7.1 has been discontinued, and its only release (0.7.1) yanked from PyPI: pip cannot safely support multiple packages that install the same module files, and restructuring the existing packages around a shared core would break in-place upgrades. Existing `scenedetect-core` installs keep working but will not receive updates; continue to install `scenedetect` or `scenedetect-headless` as usual. + - [improvement] `HistogramDetector` (`detect-hist`) default `threshold` changed from 0.05 to 0.20 and default `bins` from 256 to 128, calibrated from the [benchmark sweep](https://www.scenedetect.com/benchmarks/) for significantly better accuracy. Default output for this detector will change [#559](https://github.com/Breakthrough/PySceneDetect/issues/559) + - [improvement] `HashDetector` (`detect-hash`) default `threshold` changed from 0.395 to 0.35 and default `size` from 16 to 8, calibrated from the [benchmark sweep](https://www.scenedetect.com/benchmarks/) for better accuracy. Default output for this detector will change, including the statsfile metric key (now `hash_dist [size=8 lowpass=2]`) [#559](https://github.com/Breakthrough/PySceneDetect/issues/559) diff --git a/website/pages/cli.md b/website/pages/cli.md index 5876eeb0..78e583fc 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -7,19 +7,19 @@ See [the documentation](../docs/latest/) for a complete reference to the `scened Split input video on each fast cut using `ffmpeg`: -```rst +```bash scenedetect -i video.mp4 split-video ``` Save some frames from each cut: -```rst +```bash scenedetect -i video.mp4 save-images ``` Skip the first 10 seconds of the input video: -```rst +```bash scenedetect -i video.mp4 time -s 10s ``` @@ -31,11 +31,11 @@ As a concrete example to become familiar with PySceneDetect, let's use the follo You can [download the clip from here](https://github.com/Breakthrough/PySceneDetect/raw/refs/heads/resources/tests/resources/goldeneye.mp4) (right-click and save the video in your working directory as `goldeneye.mp4`). -Let's split this scene into clips on each fast cut. This means we need to use content-aware detecton mode (`detect-content`) or adaptive mode (`detect-adaptive`). If the video instead contains fade-in/fade-out transitions you want to find, you can use `detect-threshold` instead. If no detector is specified, `detect-adaptive` will be used by default. +Let's split this scene into clips on each fast cut. This means we need to use content-aware detection mode (`detect-content`) or adaptive mode (`detect-adaptive`). If the video instead contains fade-in/fade-out transitions you want to find, you can use `detect-threshold` instead. If no detector is specified, `detect-adaptive` will be used by default. Let's first save a scene list in CSV format and generate some images of each scene to check the output: -```rst +```bash scenedetect --input goldeneye.mp4 detect-adaptive list-scenes save-images ``` @@ -64,7 +64,7 @@ Running the above command, in the working directory, you should see a file `gold The `split-video` command can be used to automatically split the input video using `ffmpeg` or `mkvmerge`. For example: -```rst +```bash scenedetect -i goldeneye.mp4 split-video ``` @@ -77,7 +77,7 @@ You can also specify `-h` / `--high-quality` to produces near lossless results, PySceneDetect can look for fades in/out using `detect-threshold` (comparing each frame to a set black level) or find fast cuts using `detect-content` (compares each frame looking for changes in content). There also is `detect-adaptive`, which uses the same scoring as `detect-content`, but compares the ratio of each frame score to its neighbors. -Each mode has slightly different parameters, and is described in detail below. Most detector parameters can also be [set with a config file](http://scenedetect.com/projects/Manual/en/latest/cli/config_file.html). +Each mode has slightly different parameters, and is described in detail below. Most detector parameters can also be [set with a config file](https://www.scenedetect.com/docs/latest/cli/config_file.html). In general, use `detect-threshold` mode if you want to detect scene boundaries using fades/cuts in/out to black. If the video uses a lot of fast cuts between content, and has no well-defined scene boundaries, you should use the `detect-adaptive` or `detect-content` modes. Once you know what detection mode to use, you can try the parameters recommended below, or generate a statistics file (using the `-s` / `--stats` flag) in order to determine the correct parameters - specifically, the proper threshold value. @@ -86,18 +86,18 @@ In general, use `detect-threshold` mode if you want to detect scene boundaries u Unlike threshold mode, content-aware mode looks at the *difference* between each pair of adjacent frames, triggering a scene break when this difference exceeds the threshold value. -The optimal threshold can be determined by generating a stats file (`-s`), opening it with a spreadsheet editor (e.g. Excel), and examining the `content_val` column ([example](../img/goldeneye-stats.png)). This value should be very small between similar frames, and grow large when a big change in content is noticed (look at the values near frame numbers/times where you know a scene change occurs). The threshold value should be set so that most scenes fall below the threshold value, and scenes where changes occur should *exceed* the threshold value (thus triggering a scene change). +The optimal threshold can be determined by generating a stats file (`-s`), opening it with a spreadsheet editor (e.g. Excel), and examining the `content_val` column ([example](img/goldeneye-stats.png)). This value should be very small between similar frames, and grow large when a big change in content is noticed (look at the values near frame numbers/times where you know a scene change occurs). The threshold value should be set so that most scenes fall below the threshold value, and scenes where changes occur should *exceed* the threshold value (thus triggering a scene change). ### Threshold Detection Threshold-based mode is what most traditional scene detection programs use, which looks at the average intensity of the *current* frame, triggering a scene break when the intensity falls below the threshold (or crosses back upwards). The default threshold when using the `detect-threshold` is `12` (e.g. `detect-threshold` is the same as `detect-threshold --threshold 12` when the `-t` / `--threshold` option is not supplied), which is a good value to try when detecting fade outs to black on most videos. -```rst +```bash scenedetect -i my_video.mp4 -s my_video.stats.mp4 detect-threshold ``` -```rst +```bash scenedetect -i my_video.mp4 -s my_video.stats.mp4 detect-threshold -t 20 ``` @@ -113,17 +113,17 @@ The `detect-adaptive` mode compares each frame's score as calculated by `detect- ## Detection Parameters -Detectors take a variety of parameters, which can be [configured via command-line](http://scenedetect.com/projects/Manual/en/latest/cli/detectors.html) or by [using a config file](http://scenedetect.com/projects/Manual/en/latest/cli/config_file.html). If the default parameters do not produce correct results, you can generate a stats file using the `-s` / `--stats` option. +Detectors take a variety of parameters, which can be [configured via command-line](https://www.scenedetect.com/docs/latest/cli/detectors.html) or by [using a config file](https://www.scenedetect.com/docs/latest/cli/config_file.html). If the default parameters do not produce correct results, you can generate a stats file using the `-s` / `--stats` option. For example, with `detect-content`, if the default threshold of `27` does not produce correct results, we can determine the proper threshold by first generating a stats file: -```rst +```bash scenedetect --input goldeneye.mp4 --stats goldeneye.stats.csv detect-adaptive ``` We can then plot the values of the `content_val` column: -goldeneye.mp4 statistics graph +goldeneye.mp4 statistics graph The peaks in values correspond to the scene breaks in the input video. In some cases the threshold may need to be raised or lowered accordingly. @@ -159,19 +159,19 @@ Specifying the `time` command allows control over what portion of the video PySc For example, let's say we have a video shot at 30 FPS, and want to analyze only the segment from the 5 to the 6.5 minute mark in the video (we want to analyze the 90 seconds [2700 frames] between 00:05:00 and 00:06:30). The following commands are all thus equivalent in this regard (assuming we are using the content detector): -```rst +```bash scenedetect -i my_video.mp4 time --start 00:05:00 --end 00:06:30 ``` -```rst +```bash scenedetect -i my_video.mp4 time --start 300s --end 390s ``` -```rst +```bash scenedetect -i my_video.mp4 time --start 300s --duration 90s ``` -```rst +```bash scenedetect -i my_video.mp4 time --start 300s --duration 2700 ``` @@ -198,7 +198,7 @@ Specifying a config file path using -c/--config overrides the user config file. The syntax of a configuration file is: -``` +```ini [command] option_a = value #comment @@ -207,7 +207,7 @@ option_b = 1 ### Example -``` +```ini [global] default-detector = detect-content min-scene-len = 0.8s diff --git a/website/pages/contributing.md b/website/pages/contributing.md index c9662e3d..7e3f55d9 100644 --- a/website/pages/contributing.md +++ b/website/pages/contributing.md @@ -41,7 +41,7 @@ Research into detection methods and performance are ongoing. All contributions i ### GUI -A graphical user interface will be crucial for making PySceneDetect approchable by a wider audience. There have been several suggested designs, but nothing concrete has been developed yet. Any proposed solution for the GUI should work across Windows, Linux, and OSX. +A graphical user interface will be crucial for making PySceneDetect approachable by a wider audience. There have been several suggested designs, but nothing concrete has been developed yet. Any proposed solution for the GUI should work across Windows, Linux, and OSX. ### Localization diff --git a/website/pages/copyright.md b/website/pages/copyright.md index b88f7f22..d522083c 100644 --- a/website/pages/copyright.md +++ b/website/pages/copyright.md @@ -1,12 +1,12 @@ ## PySceneDetect License Agreement -```md +```text 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 diff --git a/website/pages/docs.md b/website/pages/docs.md index 346036b2..8840e94f 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,14 @@ ## Stable * [latest](latest/) + * [v0.7](0.7/) + +## Development + + * [head](head/) + +## Legacy + * [v0.6.7](0.6.7/) * [v0.6.6](0.6.6/) * [v0.6.5](0.6.5/) @@ -11,7 +19,3 @@ * [v0.6.3](0.6.3/) * [v0.6.2](0.6.2/) * [v0.6.1](0.6.1/) - -## In Development - - * [head](head/) diff --git a/website/pages/download.md b/website/pages/download.md index ffa78d12..0dd1c64c 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -6,27 +6,50 @@ PySceneDetect is completely free software, and can be downloaded from the links PySceneDetect requires at least Python 3.10 or higher. -## Install via pip       +## Install via pip      
-

Including OpenCV (recommended):

-

pip install --upgrade scenedetect[opencv]

-

Including Headless OpenCV (servers):

-

pip install --upgrade scenedetect[opencv-headless]

+

Standard install (recommended):

+
pip install --upgrade scenedetect
+

Headless install (servers, no GUI libs):

+
pip install --upgrade scenedetect-headless
-PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi.org/project/scenedetect/). +PySceneDetect is available via `pip` as two packages: + + - [`scenedetect`](https://pypi.org/project/scenedetect/): full install with the CLI, depends on `opencv-python` + - [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/): full install with the CLI, depends on `opencv-python-headless` (servers/containers without GUI libraries) + +Both provide the same `scenedetect` Python module -- install only one of them. ## Windows Build (64-bit Only)  
-

Latest Release: v0.6.7

-

  Release Date:  August 24, 2025

-  Installer  (recommended)      -  Portable .zip      +

Latest Release: v0.7.1

+

  Release Date:  July 21, 2026

+  Installer  (recommended)      +  Portable .zip        Getting Started
+## Docker Image   + +Official container images are published at [ghcr.io/breakthrough/pyscenedetect](https://github.com/breakthrough/PySceneDetect/pkgs/container/pyscenedetect). The image includes the full CLI, all optional backends (PyAV, MoviePy), and the external tools used for video splitting (`ffmpeg`, `mkvmerge`) -- no other setup is required: + +```bash +docker pull ghcr.io/breakthrough/pyscenedetect +docker run --rm ghcr.io/breakthrough/pyscenedetect version +``` + +To process videos, mount the folder containing them into the container (the image runs as a non-root user, so output files are written with regular permissions): + +```bash +docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect \ + -i /files/video.mp4 detect-adaptive split-video -o /files +``` + +The `latest` tag (the default when no tag is given) points to the most recent recommended build, the `main` tag tracks the development branch, and version tags (e.g. `0.7.1`) point to specific releases. `podman` can be used in place of `docker` in the commands above. + ## Post Installation After installation, you can call PySceneDetect from any terminal/command prompt by typing `scenedetect` (try running `scenedetect --help`, or `scenedetect version`). If you encounter any runtime errors while running PySceneDetect, ensure that you have all the required dependencies listed in the System Requirements section above (you should be able to `import numpy` and `import cv2`). If you encounter any issues or want to make a feature request, feel free to [report any bugs or share some feature requests/ideas](contributing.md) on the [issue tracker](https://github.com/Breakthrough/PySceneDetect/issues) and help make PySceneDetect even better. @@ -36,13 +59,13 @@ After installation, you can call PySceneDetect from any terminal/command prompt ### Python Packages -PySceneDetect requires [Python 3](https://www.python.org/) and the following packages: +PySceneDetect requires [Python 3](https://www.python.org/) and the following packages, all of which the `scenedetect` and `scenedetect-headless` packages install automatically: - - [OpenCV](http://opencv.org/): `pip install opencv-python` + - [OpenCV](http://opencv.org/): `pip install opencv-python` (any `opencv-python*` variant works) - [Numpy](https://numpy.org/): `pip install numpy` - - [Click](https://click.palletsprojects.com): `pip install Click` - - [tqdm](https://github.com/tqdm/tqdm): `pip install tqdm` - - [appdirs](https://github.com/ActiveState/appdirs): `pip install appdirs` + - [Click](https://click.palletsprojects.com): `pip install click` (command-line interface only) + - [tqdm](https://github.com/tqdm/tqdm): `pip install tqdm` (optional, enables progress bars) + - [platformdirs](https://github.com/tox-dev/platformdirs): `pip install platformdirs` (command-line interface only) Optional packages: diff --git a/website/pages/faq.md b/website/pages/faq.md index bcbb0224..0756fa33 100644 --- a/website/pages/faq.md +++ b/website/pages/faq.md @@ -4,24 +4,32 @@ #### How can I fix `ImportError: No module named cv2`? -You need to install OpenCV for PySceneDetect to properly work. If you're using `pip`, you can install it as follows: +As of PySceneDetect 0.7, the OpenCV dependency is bundled with the install. The standard `scenedetect` package depends on `opencv-python`: -```md -pip install scenedetect[opencv] +```bash +pip install scenedetect ``` -Note that you may need to use a different/older version depending on your Python version. You can also use the headless package if you're running a server: +For server environments without GUI libraries, install the headless variant instead, which depends on `opencv-python-headless`: +```bash +pip install scenedetect-headless +``` + +Both packages ship the same `scenedetect` Python module -- install only one of them. + +For projects that need a different OpenCV variant (e.g. `opencv-contrib-python`), install it *pinned to the same version* as the `opencv-python` variant your scenedetect package pulled in, so the two resolve to identical `cv2` files: -```md -pip install scenedetect[opencv-headless] +```bash +pip install scenedetect +pip install "opencv-contrib-python==$(pip show opencv-python | grep ^Version | cut -d' ' -f2)" ``` -Unlike calling `pip install opencv-python`, the above commands will download and install the correct OpenCV version based on the Python version you are running. +Mixing OpenCV variants at *different* versions corrupts the shared `cv2` install. First-class support for choosing your own OpenCV variant is tracked in [#558](https://github.com/Breakthrough/PySceneDetect/issues/558). #### How can I enable video splitting support? -Video splitting is performed by `ffmpeg` ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html)) or `mkvmerge` (https://mkvtoolnix.download/downloads.html) depending on which command line arguments are used. Ensure the tool is available and somewhere in your system's PATH folder. +Video splitting is performed by `ffmpeg` ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html)) or `mkvmerge` ([https://mkvtoolnix.download/downloads.html](https://mkvtoolnix.download/downloads.html)) depending on which command line arguments are used. Ensure the tool is available and somewhere in your system's PATH folder. #### How can I fix the error `Cannot split video due to too many scenes`? @@ -37,12 +45,12 @@ Unfortunately, the underlying library used to perform video I/O was unable to op This can also happen due to videos having multiple audio tracks (as per [#179](https://github.com/Breakthrough/PySceneDetect/issues/179)). If the PyAV backend does not succeed in processing the video, as a workaround you can remove the audio track using either `ffmpeg` or `mkvmerge`: -```md +```bash ffmpeg -i input.mp4 -c copy -an output.mp4 ``` Or: -```md +```bash mkvmerge -o output.mkv input.mp4 ``` diff --git a/website/pages/features.md b/website/pages/features.md index ab1dc736..ba2d6f4e 100644 --- a/website/pages/features.md +++ b/website/pages/features.md @@ -50,7 +50,7 @@ PySceneDetect implements a variety of different detection algorithms which can b - **content-aware scene detection** (`detect-hist`): uses differences in histograms of Y channel of frames after conversion to YUV (fast cut) - **threshold scene detection** (`detect-threshold`): uses average frame intensity (brightness) to detect slow transitions (fade in/out) - By default, detection methods are tuned to provide high performance during processing, while maintaining reasonable accuracy. Each detection method is configurable, and different parameters can be changed for specific use cases. See [the documentation](docs.md) for details. + By default, detection methods are tuned to provide high performance during processing, while maintaining reasonable accuracy. Each detection method is configurable, and different parameters can be changed for specific use cases. See [the documentation](docs.md) for details, and [the benchmarks page](benchmarks.md) for how each detector scores on public shot-boundary-detection datasets. ------------------------------------------------------------------------ @@ -62,7 +62,7 @@ Future version roadmaps are now [tracked as milestones (link)](https://github.co ### Planned Features -The following features are under consideration for future releases. Any contributions towards completing these features are most welcome (pull requests may be accpeted via Github). +The following features are under consideration for future releases. Any contributions towards completing these features are most welcome (pull requests may be accepted via Github). - graphical interface (GUI) - automatic threshold detection for the current scene detection methods (or just output message indicating "Predicted Threshold: X") diff --git a/website/pages/img/benchmark-f1-defaults.svg b/website/pages/img/benchmark-f1-defaults.svg new file mode 100644 index 00000000..4b7ccfd1 --- /dev/null +++ b/website/pages/img/benchmark-f1-defaults.svg @@ -0,0 +1,319 @@ + + + + + + + + 2026-07-17T21:49:19.313603 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + AdaptiveDetector + + + + + + ContentDetector + + + + + + HashDetector + + + + + + HistogramDetector + + + + + + + + + + + 0 + + + + + + + + + 20 + + + + + + + + + 40 + + + + + + + + + 60 + + + + + + + + + 80 + + + + + + + + + 100 + + + + Hard-cut F1 (tolerance 0) + + + + + + + + + + + + + + + + + + + 92 + + + 87 + + + 83 + + + 80 + + + + + + + + + + + + + + + 74 + + + 69 + + + 65 + + + 58 + + + + + + + + + + + + + + + 56 + + + 56 + + + 44 + + + 20 + + + Detection accuracy at shipped defaults + + + + + + + BBC + + + + + + AutoShot + + + + + + ClipShots + + + + + + + + + + diff --git a/website/pages/img/benchmark-f1-optimal.svg b/website/pages/img/benchmark-f1-optimal.svg new file mode 100644 index 00000000..f2887103 --- /dev/null +++ b/website/pages/img/benchmark-f1-optimal.svg @@ -0,0 +1,472 @@ + + + + + + + + 2026-07-17T21:49:21.859417 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + ContentDetector + + + + + + AdaptiveDetector + + + + + + HashDetector + + + + + + HistogramDetector + + + + + + + + + + + 0 + + + + + + + + + 20 + + + + + + + + + 40 + + + + + + + + + 60 + + + + + + + + + 80 + + + + + + + + + 100 + + + + Hard-cut F1 (tolerance 1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Accuracy after parameter tuning + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BBC + + + + + + AutoShot + + + + + + ClipShots + + + + + + v0.7 default + + + + + + + + + + + per-dataset optimum + + + + + + + + + diff --git a/website/pages/img/benchmark-sweep-curves.svg b/website/pages/img/benchmark-sweep-curves.svg new file mode 100644 index 00000000..f7fc6fc6 --- /dev/null +++ b/website/pages/img/benchmark-sweep-curves.svg @@ -0,0 +1,913 @@ + + + + + + + + 2026-07-17T21:49:20.635292 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + 15 + + + + + + 20 + + + + + + 25 + + + + + + 30 + + + + + + 35 + + + + + + + + + + + 0 + + + + + + + + + 20 + + + + + + + + + 40 + + + + + + + + + 60 + + + + + + + + + 80 + + + + + + + + + 100 + + + + + + + + + + + + + + + + + detect-content (min_scene_len=0.6s) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + + + + + + 0 + + + + + + + + + 20 + + + + + + + + + 40 + + + + + + + + + 60 + + + + + + + + + 80 + + + + + + + + + 100 + + + + + + + + + + + + + + + + + detect-adaptive (window_width=3, min_scene_len=0.6s) + + + + + + + + + + + + + + + + + + + + + + + + + + 0.25 + + + + + + 0.30 + + + + + + 0.35 + + + + + + 0.40 + + + + + + 0.45 + + + + + + 0.50 + + + + + + 0.55 + + + + + + + + + + + 0 + + + + + + + + + 20 + + + + + + + + + 40 + + + + + + + + + 60 + + + + + + + + + 80 + + + + + + + + + 100 + + + + + + + + + + + + + + + + + detect-hash (size=8) + + + + + + + + + + + + + + + + + + + + + + + + + + 0.05 + + + + + + 0.10 + + + + + + 0.15 + + + + + + 0.20 + + + + + + 0.25 + + + + + + 0.30 + + + + + + 0.35 + + + + + + + + + + + 0 + + + + + + + + + 20 + + + + + + + + + 40 + + + + + + + + + 60 + + + + + + + + + 80 + + + + + + + + + 100 + + + + + + + + + + + + + + + + + detect-hist (bins=128) + + + + + + + + + + + + + + + + + + + Hard-cut F1 (tolerance 1) vs. threshold + + + + + + + BBC + + + + + + AutoShot + + + + + + ClipShots + + + + + + + + + + + + + + + + + + diff --git a/website/pages/img/favicon.ico b/website/pages/img/favicon.ico index 019c8615..bf8cbf10 100644 Binary files a/website/pages/img/favicon.ico and b/website/pages/img/favicon.ico differ diff --git a/website/pages/img/pyscenedetect_logo_small_darkmode.png b/website/pages/img/pyscenedetect_logo_small_darkmode.png new file mode 100644 index 00000000..5b080af2 Binary files /dev/null and b/website/pages/img/pyscenedetect_logo_small_darkmode.png differ diff --git a/website/pages/index.md b/website/pages/index.md index 916b2281..8b2d88ed 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -3,7 +3,7 @@ PySceneDetect
-

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

+

  Latest Release: v0.7.1 (July 21, 2026)

  Download        Changelog        Documentation        Getting Started
@@ -13,7 +13,7 @@ Split video on each fast cut using [command line (more examples)](cli.md): -```rst +```bash scenedetect -i video.mp4 split-video ``` @@ -21,8 +21,9 @@ Split video on each fast cut using [Python API (docs)](docs.md): ```python from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg -scene_list = detect('my_video.mp4', AdaptiveDetector()) -split_video_ffmpeg('my_video.mp4', scene_list) + +scene_list = detect("my_video.mp4", AdaptiveDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) ``` diff --git a/website/pages/js/helper.js b/website/pages/js/helper.js new file mode 100644 index 00000000..72e366b1 --- /dev/null +++ b/website/pages/js/helper.js @@ -0,0 +1,28 @@ +// Adds a copy-to-clipboard button to code blocks (the readthedocs theme has no +// built-in equivalent of mkdocs-material's `content.code.copy` feature). +document.addEventListener("DOMContentLoaded", function () { + var blocks = document.querySelectorAll(".rst-content pre"); + blocks.forEach(function (pre) { + var code = pre.querySelector("code"); + if (!code) { + return; + } + var button = document.createElement("button"); + button.className = "copy-btn"; + button.type = "button"; + button.title = "Copy to clipboard"; + button.setAttribute("aria-label", "Copy to clipboard"); + button.innerHTML = ''; + button.addEventListener("click", function () { + navigator.clipboard.writeText(code.innerText.trim()).then(function () { + button.innerHTML = ''; + button.classList.add("copied"); + setTimeout(function () { + button.innerHTML = ''; + button.classList.remove("copied"); + }, 600); + }); + }); + pre.appendChild(button); + }); +}); diff --git a/website/pages/similar.md b/website/pages/similar.md index 6e5173be..94b017cb 100644 --- a/website/pages/similar.md +++ b/website/pages/similar.md @@ -9,5 +9,5 @@ The following is a list of programs or commands also performing scene cut analys - [Matlab Scene Change Detection](http://www.mathworks.com/help/vision/examples/scene-change-detection.html) - requires Matlab and Simulink/Computer Vision Toolbox, uses feature extraction and edge detection - [chaptertool](https://github.com/Mtillmann/chaptertool) - CLI/Web tool that converts PySceneDetect output to other formats - [TransNetV2](https://github.com/soCzech/TransNetV2) - Shot Boundary Detection Neural Network (2020) - - [AutoShot] https://github.com/wentaozhu/AutoShot - Shot Boundary Detection Neural Network, based on a neural architecture search (2023) + - [AutoShot](https://github.com/wentaozhu/AutoShot) - Shot Boundary Detection Neural Network, based on a neural architecture search (2023) diff --git a/website/pages/style.css b/website/pages/style.css index 348f44f8..8c59567a 100644 --- a/website/pages/style.css +++ b/website/pages/style.css @@ -23,4 +23,77 @@ #side-nav-logo { margin-bottom: -1em; +} + +/* Benchmark results tables (benchmarks.md). F1 cells are shaded on a single-hue + sequential scale: darker = higher score. */ +.bm-table { + border-collapse: collapse; + margin-bottom: 24px; +} +.bm-table th, .bm-table td { + border: 1px solid #e1e0d9; + padding: 6px 12px; + text-align: center; +} +.bm-table td:first-child { + text-align: left; +} +.bm-t1 { background-color: #6da7ec; } /* F1 >= 80 */ +.bm-t2 { background-color: #9ec5f4; } /* F1 60-79 */ +.bm-t3 { background-color: #cde2fb; } /* F1 40-59 */ + +/* Copy-to-clipboard button injected into code blocks by js/helper.js. */ +.rst-content pre { + position: relative; +} +.rst-content pre .copy-btn { + position: absolute; + top: 4px; + right: 4px; + padding: 2px 8px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: #9a9a9a; + cursor: pointer; + font-size: 14px; + line-height: 1.5; +} +.rst-content pre:hover .copy-btn, +.rst-content pre .copy-btn:focus { + border-color: #c4c4c4; + background: rgba(255, 255, 255, 0.8); + color: #404040; +} +.rst-content pre .copy-btn.copied, +.rst-content pre .copy-btn.copied:focus { + color: #27ae60; +} + +/* Prominent pip install commands inside the download page "important" divs: + full-width like regular code blocks, but white with larger bold text. */ +.rst-content .important h4:has(+ pre.command) { + margin-bottom: 6px; +} +.rst-content .important pre.command { + margin: 0 0 28px 0; + padding: 6px 42px 6px 12px; /* right padding leaves room for the copy button */ + background: #fff; + border: 1px solid #e1e4e5; +} +.rst-content .important pre.command code { + font-size: 120%; + font-weight: 700; + color: #404040; + background: transparent; + border: none; + padding: 0; +} +.rst-content .important pre.command:last-child { + margin-bottom: 4px; /* tighten space at the bottom of the box */ +} +.rst-content .important pre.command .copy-btn { + top: 50%; + transform: translateY(-50%); } \ No newline at end of file diff --git a/website/requirements.txt b/website/requirements.txt deleted file mode 100644 index 8455efc2..00000000 --- a/website/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -mkdocs==1.5.2 -jinja2==3.1.5