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_app.md b/.github/ISSUE_TEMPLATE/scenedetect_app.md index b5ba0c70..49658a85 100644 --- a/.github/ISSUE_TEMPLATE/scenedetect_app.md +++ b/.github/ISSUE_TEMPLATE/scenedetect_app.md @@ -20,7 +20,7 @@ Copy the output of running the application here. Where possible, generate a debu **Environment:** -The operating system and how you installed PySceneDetect may be relevant to the issue. Please run `scenedetect version --all` and copy the output here, or provide other details on how PySceneDetect was installed. +The operating system and how you installed PySceneDetect may be relevant to the issue. Please run `scenedetect version` and copy the output here, or provide other details on how PySceneDetect was installed. **Media/Files:** diff --git a/.github/ISSUE_TEMPLATE/scenedetect_package.md b/.github/ISSUE_TEMPLATE/scenedetect_package.md index a6f43779..7fbca92c 100644 --- a/.github/ISSUE_TEMPLATE/scenedetect_package.md +++ b/.github/ISSUE_TEMPLATE/scenedetect_package.md @@ -20,7 +20,7 @@ split_video_ffmpeg('my_video.mp4', scene_list) **Environment:** -Run `scenedetect version --all` and include the output. This will describe the environment/OS/platform and versions of dependencies you have installed. +Run `scenedetect version` and include the output. This will describe the environment/OS/platform and versions of dependencies you have installed. **Media/Files:** 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 efc2449c..495d68c3 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: @@ -27,16 +33,16 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.9"] + python-version: ["3.13"] env: - ffmpeg-version: "7.0" + 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' @@ -44,8 +50,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools - pip install -r dist/requirements_windows.txt - pip install -r docs/requirements.txt + pip install .[docs] + pip install --upgrade -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg - name: Download Resources run: | @@ -60,14 +66,20 @@ jobs: file: 'ffmpeg-${{ env.ffmpeg-version }}-full_build.7z' - name: Unit Test + shell: bash run: | 7z e ffmpeg-${{ env.ffmpeg-version }}-full_build.7z ffmpeg.exe -r + 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 --ignore-installer - pyinstaller dist/scenedetect.spec + python scripts/pre_release.py + pyinstaller packaging/windows/scenedetect.spec - name: Build Documentation run: | @@ -78,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 @@ -90,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 @@ -100,18 +111,24 @@ jobs: runs-on: windows-latest needs: build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: 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 run: | + echo Testing binary ./build/scenedetect version - ./build/scenedetect -i tests/resources/goldeneye.mp4 -b opencv detect-content time --end 00:10:00 - ./build/scenedetect -i tests/resources/goldeneye.mp4 -b pyav detect-content time --end 00:10:00 - ./build/scenedetect -i tests/resources/goldeneye.mp4 detect-content time --end 00:10:00 split-video + echo Test OpenCV + ./build/scenedetect -i tests/resources/goldeneye.mp4 -b opencv detect-content time --end 10s + echo Test PyAV + ./build/scenedetect -i tests/resources/goldeneye.mp4 -b pyav detect-content time --end 10s + echo Test moviepy + ./build/scenedetect -i tests/resources/goldeneye.mp4 -b moviepy detect-content time --end 10s + echo Test split-video + ffmpeg + ./build/scenedetect -i tests/resources/goldeneye.mp4 detect-content time --end 10s split-video diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69cdcfbd..d6f0f6ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,103 +6,154 @@ 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-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] - exclude: - # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. - - os: macos-14 - python-version: "3.7" - + os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] env: # Version is extracted below and used to find correct package install path. scenedetect_version: "" - # Setuptools must be pinned for the Python 3.7 builders. - setuptools_version: "${{ matrix.python-version == '3.7' && '==62.3.4' || '' }}" steps: - - uses: actions/checkout@v4 + - 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' - name: Install Dependencies run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools${{ env.setuptools_version }} - pip install av opencv-python-headless --only-binary :all: - pip install -r requirements_headless.txt + python -m pip install --upgrade pip build wheel virtualenv setuptools + 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 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 + + - name: Smoke Test Package (Headless Wheel) + shell: bash run: | - python -m pip install dist/scenedetect-${{ env.scenedetect_version }}-py3-none-any.whl + 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 - python -m pip uninstall -y scenedetect + + - 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.12' && matrix.os == 'ubuntu-latest' }} - uses: actions/upload-artifact@v4 + if: ${{ matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' }} + uses: actions/upload-artifact@v6 with: name: scenedetect-dist path: | diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml new file mode 100644 index 00000000..199c5ef3 --- /dev/null +++ b/.github/workflows/check-docs.yml @@ -0,0 +1,60 @@ +name: Check Documentation + +on: + schedule: + - cron: '0 0 * * *' + pull_request: + paths: + - docs/** + - scenedetect/** + - website/** + push: + paths: + - docs/** + - scenedetect/** + - website/** + branches: + - main + - 'releases/**' + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip build wheel virtualenv + pip install .[docs,website] + pip install -r packaging/windows/requirements.txt + + + - name: Check CLI Documentation + shell: bash + run: | + if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then + echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" + 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..0ce991b7 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,72 @@ +# 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 + 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 2f41c6d7..12ccd7a2 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -13,17 +13,21 @@ on: jobs: update_docs: runs-on: ubuntu-latest + 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.4' 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,10 +50,20 @@ jobs: - name: Setup Environment run: | python -m pip install --upgrade pip build wheel virtualenv - pip install -r docs/requirements.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 + - name: Check CLI Documentation + shell: bash + run: | + if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then + echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" + echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + exit 1 + fi + - name: Generate Docs run: | sphinx-build -b html docs build diff --git a/.github/workflows/generate-website.yml b/.github/workflows/generate-website.yml index 328ac2cc..6e924106 100644 --- a/.github/workflows/generate-website.yml +++ b/.github/workflows/generate-website.yml @@ -12,12 +12,14 @@ on: jobs: update_site: runs-on: ubuntu-latest + 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 +27,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 new file mode 100644 index 00000000..de468367 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,118 @@ +name: Publish PyPI Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag To Publish' + required: true + environment: + description: 'PyPI Environment' + required: true + type: choice + options: + - 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 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: 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 }} + url: ${{ github.event.inputs.environment == 'testpypi' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} + + permissions: + id-token: write # mandatory for trusted publishing + actions: read # for cross-workflow artifact download + + steps: + - 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: | + 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/ + 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 == '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 58% rename from .github/workflows/check-code-format.yml rename to .github/workflows/static-analysis.yml index 502579a1..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,19 +30,18 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - python -m pip install av opencv-python-headless --only-binary ":all:" - python -m pip install -r requirements_headless.txt - - - 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 1171c56e..b2f656fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ docs/_build/ +docs/STYLE.md website/build/ +scripts/local/ tests/resources/* *.mp4 *.jpg @@ -9,7 +11,17 @@ tests/resources/* *.mkv *.m4v *.csv +*.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 @@ -83,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 b47d7c86..4ce8d4ca 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.4 (June 10, 2024) +### 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,6 +49,12 @@ 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): @@ -66,8 +74,8 @@ 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].get_frames(), - scene[1].get_timecode(), scene[1].get_frames(),)) + 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): @@ -98,18 +106,22 @@ def split_video_into_scenes(video_path, threshold=27.0): See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for more examples. +**Benchmark**: + +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 @@ -121,5 +133,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 72c6a84f..0ffafc7c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,24 +2,36 @@ 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 environment: matrix: - - PYTHON: "C:\\Python39-x64" + - PYTHON: "C:\\Python313-x64" # Encrypted AdvancedInstaller License ai_license_secret: - secure: MOkULlGPSi0C1Hg2PU1h2SZg/eyQnPQhRJ1XFlavfMKMOoX9hY4pSjpdgW3psSau + secure: QRCPoNYF1nqgXDn7pHgBzg== ai_license_salt: - secure: /LlGOUGZk8HQgrW6txtssTt8I6Z6pU7K3XOcqTqr2iKX4vLO3ZTdILgL/6M6u7gWVdRoUYfbxm4JVYjs4hfcmQ== + secure: +Gy+SRk8JUsaM+5pMEKITiJxdLilrxHpkKlrZzR3C9DPwdgYLGxt5sJn6uXuAJg7e6JsKHcT7tRks/HcSKkHPw== + ffmpeg_version: "8.1.2" # SignPath Config for Code Signing deploy: @@ -27,6 +39,8 @@ deploy: url: https://app.signpath.io/API/v1/f2efa44c-5b5c-45f2-b44f-8f9dde708313/Integrations/AppVeyor?ProjectSlug=PySceneDetect&SigningPolicySlug=release-signing authorization: secure: FBgWCaxCCKOqc2spYf5NGWSNUGLbT5WeuC5U0k4Of1Ids9n51YWxhGlMyzLbdNBFe64RUcOSzk/N3emlQzbsJg== + on: + APPVEYOR_REPO_TAG: true # keep casing this way for Linux builds where variables are case-sensitive install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * @@ -34,54 +48,77 @@ install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - - python -m pip install --upgrade pip - - python -m pip install -r docs/requirements.txt - - python -m pip install --upgrade -r dist/requirements_windows.txt - # 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/6.0/ffmpeg-6.0-full_build.7z - - 7z e ffmpeg-6.0-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r + - 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 - - 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\ - - move 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 21.8.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 .. @@ -95,7 +132,9 @@ test_script: - git checkout refs/remotes/origin/resources -- tests/resources/ - move dist\scenedetect\ffmpeg.exe ffmpeg.exe # Run unit tests - - pytest + # TODO: We are at the new build time limit for this plan apparently, 10 mins. Figure out a + # strategy to deal with that (see if we can use Github as a builder?). + # - pytest # Test Windows build - move ffmpeg.exe dist\scenedetect\ffmpeg.exe - cd dist/scenedetect @@ -104,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/AutoShot/.gitkeep b/benchmark/AutoShot/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/benchmark/BBC/.gitkeep b/benchmark/BBC/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..180b0d77 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,194 @@ +# 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 new file mode 100644 index 00000000..2f4f9a3f --- /dev/null +++ b/benchmark/__main__.py @@ -0,0 +1,180 @@ +# +# 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/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 new file mode 100644 index 00000000..07a666b6 --- /dev/null +++ b/benchmark/evaluator.py @@ -0,0 +1,346 @@ +# +# 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/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 00d73f4d..00000000 --- a/dist/installer/PySceneDetect.aip +++ /dev/null @@ -1,939 +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/license65.dat.enc b/dist/installer/license65.dat.enc deleted file mode 100644 index 380139d5..00000000 Binary files a/dist/installer/license65.dat.enc and /dev/null differ 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/pre_release.py b/dist/pre_release.py deleted file mode 100644 index 11d00154..00000000 --- a/dist/pre_release.py +++ /dev/null @@ -1,79 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import sys -sys.path.append(os.path.abspath(".")) - -import scenedetect - - -VERSION = scenedetect.__version__ - -run_version_check = ("--ignore-installer" not 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}." - -with open("dist/.version_info", "wb") as f: - v = VERSION.split(".") - assert 2 <= len(v) <= 3, f"Unrecognized version format: {VERSION}" - if len(v) < 3: - v.append("0") - (maj, min, pat, bld) = v[0], v[1], v[2], 0 - # 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 - if not pat.isdigit(): - 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 -VSVersionInfo( - ffi=FixedFileInfo( -# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) -# Set not needed items to zero 0. -filevers=({maj}, {min}, {pat}, {bld}), -prodvers=({maj}, {min}, {pat}, {bld}), -# Contains a bitmask that specifies the valid bits 'flags'r -mask=0x3f, -# Contains a bitmask that specifies the Boolean attributes of the file. -flags=0x0, -# The operating system for which this file was designed. -# 0x4 - NT and there is no need to change it. -OS=0x4, -# The general type of file. -# 0x1 - the file is an application. -fileType=0x1, -# The function of the file. -# 0x0 - the function is not defined for this fileType -subtype=0x0, -# Creation date and time stamp. -date=(0, 0) -), - kids=[ -StringFileInfo( - [ - StringTable( - u'040904B0', - [StringStruct(u'CompanyName', u'github.com/Breakthrough'), - StringStruct(u'FileDescription', u'www.scenedetect.com'), - StringStruct(u'FileVersion', u'{VERSION}'), - StringStruct(u'InternalName', u'PySceneDetect'), - StringStruct(u'LegalCopyright', u'Copyright © 2024 Brandon Castellano'), - StringStruct(u'OriginalFilename', u'scenedetect.exe'), - StringStruct(u'ProductName', u'PySceneDetect'), - StringStruct(u'ProductVersion', u'{VERSION}')]) - ]), -VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) - ] -) -""".encode()) diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico deleted file mode 100644 index b52baa83..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 a4b1f675..00000000 --- a/dist/requirements_windows.txt +++ /dev/null @@ -1,9 +0,0 @@ -# PySceneDetect Requirements for Windows Build -av==10.0 -click>=8.0 -numpy -opencv-python-headless==4.10.0.82 -platformdirs -pyinstaller -pytest -tqdm \ No newline at end of file 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 new file mode 100644 index 00000000..bf8cbf10 Binary files /dev/null and b/docs/_static/favicon.ico differ diff --git a/docs/_static/pyscenedetect_logo.png b/docs/_static/pyscenedetect_logo.png index 63471a6a..26cb1b24 100644 Binary files a/docs/_static/pyscenedetect_logo.png and b/docs/_static/pyscenedetect_logo.png differ diff --git a/docs/_static/pyscenedetect_logo_small.png b/docs/_static/pyscenedetect_logo_small.png index 43365948..a0180fb9 100644 Binary files a/docs/_static/pyscenedetect_logo_small.png and b/docs/_static/pyscenedetect_logo_small.png differ diff --git a/docs/api.rst b/docs/api.rst index 5278aad0..650975b4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,42 +3,51 @@ ``scenedetect`` 🎬 Package *********************************************************************** -The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Getting Started`_ section below for some common use cases and integrations. The `scenedetect` package contains several modules: +The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Getting Started`_ section below for some common use cases and integrations. The `scenedetect` package is organized into several sub-modules: - * :ref:`scenedetect 🎬 `: Includes the :func:`scenedetect.detect ` function which takes a path and a :ref:`detector ` to find scene transitions (:ref:`example `), and :func:`scenedetect.open_video ` for video input + * :ref:`scenedetect 🎬 `: high-level functions like :func:`scenedetect.detect() ` to quickly analyze a video with any :ref:`detection algorithm ` (:ref:`example `) and get a list of timecode pairs as a result - * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). This module also contains functionality to export information about scenes in various formats: :func:`save_images ` to save images for each scene, :func:`write_scene_list ` to save scene/cut info as CSV, and :func:`write_scene_list_html ` to export scenes in viewable HTML format. + * :ref:`scenedetect.detectors 🕵️ `: detection algorithms: - * :ref:`scenedetect.detectors 🕵️ `: Detection algorithms: + * :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:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes + * :class:`HistogramDetector `: finds fast cuts using HSV histogram changes - * :mod:`HistogramDetector ` finds fast cuts using HSV histogram changes + * :class:`HashDetector `: finds fast cuts using perceptual image hashing - * :mod:`HashDetector `: finds fast cuts using perceptual image hashing + * :ref:`scenedetect.output ✂️ `: Output formats: - * :ref:`scenedetect.video_stream 🎥 `: Video input is handled through the :class:`VideoStream ` interface. Implementations for common video libraries are provided in :mod:`scenedetect.backends`: + * :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` split a video based on the detected scenes + + * :func:`save_images ` can save an arbitrary number of images from each scene + + * :func:`write_scene_list ` can be used to save scene/cut info as CSV, :func:`write_scene_list_html ` for HTML + + * :ref:`scenedetect.backends 🎥 `: PySceneDetect supports multiple libraries as an input backend: + + * OpenCV: :class:`VideoStreamCv2 ` - * OpenCV: :class:`VideoStreamCv2 ` * PyAV: :class:`VideoStreamAv ` + * MoviePy: :class:`VideoStreamMoviePy ` - * :ref:`scenedetect.video_splitter ✂️ `: Contains :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` to split a video based on the detected scenes. + * Multiple videos can be treated as a single continuous stream using :class:`VideoStreamConcat ` (e.g. ``open_video(["part1.mp4", "part2.mp4"])``) - * :ref:`scenedetect.frame_timecode ⏱️ `: Contains - :class:`FrameTimecode ` - class for storing, converting, and performing arithmetic on timecodes - with frame-accurate precision. + * :ref:`scenedetect.common ⏱️ `: common functionality such as :class:`FrameTimecode ` for timecode handling - * :ref:`scenedetect.scene_detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. + * :ref:`scenedetect.scene_manager 🎞️ `: the :class:`SceneManager ` coordinates performing scene detection on a video with one or more detectors - * :ref:`scenedetect.stats_manager 🧮 `: Contains :class:`StatsManager ` class for caching frame metrics and loading/saving them to disk in CSV format for analysis. + * :ref:`scenedetect.detector 🌐 `: the interface (:class:`SceneDetector `) that detectors must implement to be compatible with PySceneDetect - * :ref:`scenedetect.platform 🐱‍💻 `: Logging and utility functions. + * :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 + + * :ref:`scenedetect.platform 🐱‍💻 `: logging and utility functions Most types/functions are also available directly from the `scenedetect` package to make imports simpler. @@ -49,7 +58,7 @@ Most types/functions are also available directly from the `scenedetect` package .. code:: python - scenedetect<0.7 + scenedetect~=0.7 .. _scenedetect-quickstart: @@ -66,11 +75,11 @@ PySceneDetect makes it very easy to find scene transitions in a video with the : path = "video.mp4" scenes = detect(path, ContentDetector()) for (scene_start, scene_end) in scenes: - print(f'{scene_start}-{scene_end}') + print(f"{scene_start}-{scene_end}") -``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. +``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. -Here, we use :mod:`ContentDetector ` to detect fast cuts. There are :ref:`many detector types ` which can be used to find fast cuts and fades in/out. PySceneDetect can also export scene data in various formats, and can :ref:`split the input video ` automatically if `ffmpeg` is available: +Here, we use :mod:`ContentDetector ` to detect fast cuts. There are :ref:`many detector types ` which can be used to find fast cuts and fades in/out. PySceneDetect can also export scene data in various formats, and can :ref:`split the input video ` automatically if `ffmpeg` is available: .. code:: python @@ -89,36 +98,9 @@ Functions .. automodule:: scenedetect :members: -======================================================================= -Module Reference -======================================================================= - -.. toctree:: - :maxdepth: 3 - :caption: PySceneDetect Module Documentation - :name: fullapitoc - - api/detectors - api/backends - api/scene_manager - api/video_splitter - api/stats_manager - api/frame_timecode - api/scene_detector - api/video_stream - api/platform - api/migration_guide - ======================================================================= Logging ======================================================================= PySceneDetect outputs messages to a logger named ``pyscenedetect`` which does not have any default handlers. You can use :func:`scenedetect.init_logger ` with ``show_stdout=True`` or specify a log file (verbosity can also be specified) to attach some common handlers, or use ``logging.getLogger("pyscenedetect")`` and attach log handlers manually. - - -======================================================================= -Migrating From 0.5 -======================================================================= - -PySceneDetect 0.6 introduces several breaking changes which are incompatible with 0.5. See :ref:`Migration Guide ` for details on how to update your application. In addition, demonstrations of common use cases can be found in the `tests/test_api.py `_ file. diff --git a/docs/api/backends.rst b/docs/api/backends.rst index efa6b860..8ba7d47c 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -1,9 +1,9 @@ .. _scenedetect-backends: ----------------------------------------- -Backends ----------------------------------------- +-------------- +Video Backends +-------------- .. automodule:: scenedetect.backends :members: @@ -16,3 +16,6 @@ Backends .. automodule:: scenedetect.backends.moviepy :members: + +.. automodule:: scenedetect.backends.concat + :members: diff --git a/docs/api/common.rst b/docs/api/common.rst new file mode 100644 index 00000000..55cb8310 --- /dev/null +++ b/docs/api/common.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-common: + +------ +Common +------ + +.. automodule:: scenedetect.common + :members: diff --git a/docs/api/detector.rst b/docs/api/detector.rst new file mode 100644 index 00000000..97b06d3b --- /dev/null +++ b/docs/api/detector.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-detector: + +------------------ +Detector Interface +------------------ + +.. automodule:: scenedetect.detector + :members: diff --git a/docs/api/detectors.rst b/docs/api/detectors.rst index 6ec7b85c..5fbe7ff2 100644 --- a/docs/api/detectors.rst +++ b/docs/api/detectors.rst @@ -1,24 +1,53 @@ .. _scenedetect-detectors: ----------------------------------------- -Detection Algorithms ----------------------------------------- +--------- +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/frame_timecode.rst b/docs/api/frame_timecode.rst deleted file mode 100644 index b51dc388..00000000 --- a/docs/api/frame_timecode.rst +++ /dev/null @@ -1,9 +0,0 @@ - -.. _scenedetect-frame_timecode: - ---------------------------------------------------------------- -FrameTimecode ---------------------------------------------------------------- - -.. automodule:: scenedetect.frame_timecode - :members: diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index 11da142a..b481ebff 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -1,136 +1,290 @@ -.. _scenedetect-migration_guide: +.. _scenedetect-migration-guide: ---------------------------------------------------------------- -Migration Guide ---------------------------------------------------------------- +*********************************************************************** +Migration Guide (v0.7) +*********************************************************************** -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. +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. -PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. +The minimum supported Python version is now **Python 3.10**. -This page covers commonly used APIs which require updates to work with v0.6. Note that this page is not an exhaustive set of changes. For a complete list of breaking API changes, see `the changelog `_. -In some places, a backwards compatibility layer has been added to avoid breaking most applications upon release. This should not be relied upon, and will be removed in the future. You can call ``scenedetect.platform.init_logger(show_stdout=True)`` or attach a custom log handler to the ``'pyscenedetect'`` logger to help find these cases. +======================================================================= +Quick Check +======================================================================= +If your code only uses :func:`scenedetect.detect` with a built-in detector, it should work without changes: -=============================================================== -`VideoManager` Class -=============================================================== +.. code:: python + + # This still works in v0.7 + from scenedetect import detect, ContentDetector + scenes = detect("video.mp4", ContentDetector()) + + +======================================================================= +Import Changes +======================================================================= + +Several submodules have been reorganized. If you import directly from `scenedetect` you do not need to make any changes. Update imports as follows: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - v0.6 + - v0.7 + * - ``from scenedetect.frame_timecode import FrameTimecode`` + - ``from scenedetect.common import FrameTimecode`` + * - ``from scenedetect.scene_detector import SceneDetector`` + - ``from scenedetect.detector import SceneDetector`` + * - ``from scenedetect.video_splitter import split_video_ffmpeg`` + - ``from scenedetect.output import split_video_ffmpeg`` + * - ``from scenedetect.video_splitter import split_video_mkvmerge`` + - ``from scenedetect.output import split_video_mkvmerge`` + * - ``from scenedetect.scene_manager import save_images`` + - ``from scenedetect.output import save_images`` + * - ``from scenedetect.scene_manager import write_scene_list`` + - ``from scenedetect.output import write_scene_list`` + * - ``from scenedetect.scene_manager import write_scene_list_html`` + - ``from scenedetect.output import write_scene_list_html`` + * - ``from scenedetect.video_manager import VideoManager`` + - Removed. Use :func:`scenedetect.open_video` instead. + +.. note:: + + Most commonly used types and functions are also available directly from the top-level ``scenedetect`` package (e.g. ``from scenedetect import FrameTimecode``), which has not changed. + +.. 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 +======================================================================= + +If you have written a custom :class:`SceneDetector ` subclass, there are several interface changes. + +``process_frame`` Signature +----------------------------------------------------------------------- + +The ``frame_num`` parameter (``int``) has been replaced with ``timecode`` (:class:`FrameTimecode `): + +.. code:: python + + # v0.6 + class MyDetector(SceneDetector): + def process_frame(self, frame_num: int, frame_img) -> List[int]: + ... + + # v0.7 + class MyDetector(SceneDetector): + def process_frame(self, timecode: FrameTimecode, frame_img) -> List[FrameTimecode]: + ... + +The same change applies to ``post_process()``. 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 +----------------------------------------------------------------------- + +``SceneDetector`` is now a Python `abstract class `_. Subclasses **must** implement ``process_frame()``. -`VideoManager` has been deprecated and replaced with :mod:`scenedetect.backends`. For most applications, the :func:`open_video ` function should be used instead: +Removed Methods and Properties +----------------------------------------------------------------------- + +The following have been removed from the ``SceneDetector`` interface: + +- ``is_processing_required()`` - detectors can now assume they always have frame data +- ``stats_manager_required`` property - no longer needed +- ``SparseSceneDetector`` interface - removed entirely + + +======================================================================= +``FrameTimecode`` Changes +======================================================================= + +Read-Only Properties +----------------------------------------------------------------------- + +: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 - from scenedetect import open_video - video = open_video(video.mp4') + 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 :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 + + 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. -The resulting object can then be passed to a :class:`SceneManager ` when calling :meth:`detect_scenes `, or any other function/method that used to take a `VideoManager`, e.g.: +``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 - from scenedetect import open_video, SceneManager, ContentDetector - video = open_video('video.mp4') - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector(threshold=threshold)) - scene_manager.detect_scenes(video) - print(scene_manager.get_scene_list()) + 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) -See :mod:`scenedetect.backends` for examples of how to create specific backends. Where previously a list of paths was accepted, now only a single string should be provided. +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()`` +----------------------------------------------------------------------- -Seeking and Start/End Times -=============================================================== +: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``). -Instead of setting the start time via the `VideoManager`, now :meth:`seek ` to the starting time on the :class:`VideoStream ` object. +Removed Methods +----------------------------------------------------------------------- -Instead of setting the duration or end time via the `VideoManager`, now set the `duration` or `end_time` parameters when calling :meth:`detect_scenes `. +- ``previous_frame()`` - removed, use ``FrameTimecode(tc.frame_num - 1, tc)`` instead (passing a ``FrameTimecode`` as the ``fps`` argument reuses its rate) + + +======================================================================= +Framerate and Timestamp Changes +======================================================================= + +Rational Framerates +----------------------------------------------------------------------- + +: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 - from scenedetect import open_video, SceneManager, ContentDetector - video = open_video('video.mp4') - # Can be seconds (float), frame # (int), or FrameTimecode - start_time, end_time = 2.5, 5.0 - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector(threshold=threshold)) - video.seek(start_time) - # Note there is also a `duration` parameter that can also be set. - # If neither `duration` nor `end_time` is provided, the video will - # be processed from its current position until the end. - scene_manager.detect_scenes(video, end_time=end_time) - print(scene_manager.get_scene_list()) + from fractions import Fraction + video = open_video("video.mp4") + 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. -=============================================================== -`SceneManager` Class -=============================================================== +.. code:: python -The first argument of the :meth:`detect_scenes ` method has been renamed to `video` and should now be a :class:`VideoStream ` object (see above). + # 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) -=============================================================== -`save_images` Function -=============================================================== +PTS-Backed Timestamps +----------------------------------------------------------------------- -The second argument of :func:`save_images ` in :mod:`scenedetect.scene_manager` has been renamed from `video_manager` to `video`. +All backends now return presentation timestamp (PTS) backed values from :attr:`~scenedetect.video_stream.VideoStream.position`. This enables correct handling of VFR videos. -The `downscale_factor` parameter has been removed from :func:`save_images ` (use the `scale` parameter instead). To achieve the same result as the previous version, set `scale` to `1.0 / downscale_factor`. +``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. -=============================================================== -`split_video_*` Functions -=============================================================== +======================================================================= +``StatsManager`` Changes +======================================================================= -The the :mod:`scenedetect.video_splitter` functions :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` now only accept a single path as the input (first) argument. +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. -The `suppress_output` and `hide_progress` arguments to the :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` have been removed, and two new options have been added: +``StatsManager.load_from_csv()`` also accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str`` / ``bytes`` / file handles. - * `suppress_output` is now `show_output`, default is `False` - * `hide_progress` is now `show_progress`, default is `False` -This makes the API consistent with that of :class:`SceneManager `. +======================================================================= +``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 -=============================================================== -`StatsManager` Class -=============================================================== + # 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 -The :func:`save_to_csv ` and :func:`load_from_csv ` methods now accept either a `path` or an open `file` handle. -The `base_timecode` argument has been removed from :func:`save_to_csv `. It is no longer required. +======================================================================= +``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. -=============================================================== -`AdaptiveDetector` Class -=============================================================== -The `video_manager` parameter has been removed and is no longer required when constructing an :class:`AdaptiveDetector ` object. +======================================================================= +Removed APIs +======================================================================= +The following deprecated APIs have been fully removed in v0.7: -=============================================================== -Other -=============================================================== +.. list-table:: + :header-rows: 1 + :widths: 50 50 -`ThresholdDetector` Class -=============================================================== + * - Removed + - Replacement + * - ``scenedetect.video_manager`` module + - :func:`scenedetect.open_video` + * - ``base_timecode`` parameter (various functions) + - No longer needed, remove the argument + * - ``video_manager`` parameter (various functions) + - Use ``video`` parameter instead + * - ``SceneManager.get_event_list()`` + - Use :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=...)`` + - Use ``min_content_val`` parameter instead + * - ``VideoStream.read(advance=...)`` + - Call ``read()`` without the ``advance`` parameter + * - ``SparseSceneDetector`` + - No direct replacement, use ``SceneDetector`` -The `block_size` argument has been removed from the :class:`ThresholdDetector ` constructor. It is no longer required. +.. note:: + Deprecated v0.6 compatibility shims that still exist now emit warnings using the ``warnings`` module. Address any ``DeprecationWarning`` messages to prepare for future releases. -`ContentDetector` Class -=============================================================== -The `calculate_frame_score` method of :class:`ContentDetector ` has been renamed to :meth:`_calculate_frame_score `. Use new global function :func:`calculate_frame_score ` to achieve the same result. +======================================================================= +CLI Changes +======================================================================= +Removed / Renamed +----------------------------------------------------------------------- -`MINIMUM_FRAMES_PER_SECOND_*` Constants -=============================================================== +- 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. -In :mod:`scenedetect.frame_timecode` the constants `MINIMUM_FRAMES_PER_SECOND_FLOAT` and `MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT` have been replaced with :data:`MAX_FPS_DELTA `. +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``). -`get_aspect_ratio` Function -=============================================================== +Other Changes +----------------------------------------------------------------------- - The `get_aspect_ratio` function has been removed from `scenedetect.platform`. Use the :attr:`aspect_ratio ` property from the :class:`VideoStream ` object instead. +- VFR videos now work correctly with both the OpenCV and PyAV backends. +- 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 new file mode 100644 index 00000000..6c3abb37 --- /dev/null +++ b/docs/api/output.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-output: + +------ +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_detector.rst b/docs/api/scene_detector.rst deleted file mode 100644 index 98492a51..00000000 --- a/docs/api/scene_detector.rst +++ /dev/null @@ -1,9 +0,0 @@ - -.. _scenedetect-scene_detector: - -------------------------------------------------- -SceneDetector -------------------------------------------------- - -.. automodule:: scenedetect.scene_detector - :members: diff --git a/docs/api/scene_manager.rst b/docs/api/scene_manager.rst index 7dfb0b50..2a0ee6a3 100644 --- a/docs/api/scene_manager.rst +++ b/docs/api/scene_manager.rst @@ -1,9 +1,9 @@ .. _scenedetect-scene_manager: ------------------------------------------------------------------------ -SceneManager ------------------------------------------------------------------------ +------------- +Scene Manager +------------- .. automodule:: scenedetect.scene_manager :members: diff --git a/docs/api/stats_manager.rst b/docs/api/stats_manager.rst index 0abc5d89..5f96dec0 100644 --- a/docs/api/stats_manager.rst +++ b/docs/api/stats_manager.rst @@ -1,9 +1,9 @@ .. _scenedetect-stats_manager: ------------------------------------------------------------------------ -StatsManager ------------------------------------------------------------------------ +------------- +Stats Manager +------------- .. automodule:: scenedetect.stats_manager :members: diff --git a/docs/api/video_splitter.rst b/docs/api/video_splitter.rst deleted file mode 100644 index a870f129..00000000 --- a/docs/api/video_splitter.rst +++ /dev/null @@ -1,10 +0,0 @@ - - -.. _scenedetect-video_splitter: - ---------------------------------------------------------------- -Video Splitting ---------------------------------------------------------------- - -.. automodule:: scenedetect.video_splitter - :members: diff --git a/docs/api/video_stream.rst b/docs/api/video_stream.rst index aa31f50a..a090d6d9 100644 --- a/docs/api/video_stream.rst +++ b/docs/api/video_stream.rst @@ -1,9 +1,9 @@ .. _scenedetect-video_stream: ---------------------------------------------------------------- -VideoStream ---------------------------------------------------------------- +---------------- +Stream Interface +---------------- .. automodule:: scenedetect.video_stream :members: diff --git a/docs/cli.rst b/docs/cli.rst index 54d4da26..de49ad40 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1,3 +1,4 @@ +.. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified. ************************************************************************ ``scenedetect`` 🎬 Command @@ -60,13 +61,17 @@ 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, --frame-rate FPS - Override framerate with value as frames/sec. + Override frame rate with value as frames/sec. + +.. option:: --framerate FPS + + [DEPRECATED] Use :option:`-f/--frame-rate <-f>` instead. .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. TIMECODE can be specified as number of frames (:option:`-m=10 <-m>`), time in seconds (:option:`-m=2.5 <-m>`), or timecode (:option:`-m=00:02:53.633 <-m>`). + 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). Default: ``0.6s`` @@ -84,13 +89,17 @@ Options Default: ``opencv`` +.. option:: --crop X0 Y0 X1 Y1 + + 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). + .. option:: -d N, --downscale N - Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set :option:`-d=1 <-d>` to disable downscaling. + Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling. .. option:: -fs N, --frame-skip N - Skip N frames during processing. Reduces processing speed at expense of accuracy. :option:`-fs=1 <-fs>` skips every other frame processing 50% of the video, :option:`-fs=2 <-fs>` processes 33% of the video frames, :option:`-fs=3 <-fs>` processes 25%, etc... + 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... Default: ``0`` @@ -143,7 +152,7 @@ Detectors ``detect-adaptive`` ======================================================================== -Perform adaptive detection algorithm on input video. +Find fast cuts using diffs in HSL colorspace (rolling average). Two-pass algorithm that first calculates frame scores with :ref:`detect-content `, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. @@ -173,12 +182,6 @@ Options Default: ``15.0`` -.. option:: -d VAL, --min-delta-hsv VAL - - [DEPRECATED] Use :option:`-c/--min-content-val <-c>` instead. - - 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. @@ -203,7 +206,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). .. _command-detect-content: @@ -214,19 +217,19 @@ Options ``detect-content`` ======================================================================== -Perform content detection algorithm on input video. +Find fast cuts using differences in HSL (filtered). For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds :option:`-t/--threshold <-t>`. Frame scores are saved under the "content_val" column in a statsfile. Scores are calculated from several components which are also recorded in the statsfile: - - *delta_hue*: Difference between pixel hue values of adjacent frames. + - *delta_hue*: Difference between pixel hue values of adjacent frames. - - *delta_sat*: Difference between pixel saturation values of adjacent frames. + - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. Once calculated, these components are multiplied by the specified :option:`-w/--weights <-w>` to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. @@ -246,7 +249,7 @@ Options .. option:: -t VAL, --threshold VAL - Threshold (float) that frame score must exceed to trigger a cut. Refers to "content_val" in stats file. + 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. Default: ``27.0`` @@ -258,7 +261,7 @@ Options .. option:: -l, --luma-only - Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting "-w 0 0 1 0". + Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0. .. option:: -k N, --kernel-size N @@ -268,7 +271,13 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. + +.. option:: -f MODE, --filter-mode MODE + + Mode used to enforce :option:`-m/--min-scene-len <-m>` option. Can be one of: merge, suppress. + + Default: ``Mode.MERGE`` .. _command-detect-hash: @@ -283,12 +292,13 @@ Find fast cuts using perceptual hashing. The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. -Saved as the `hash_dist` metric in a statsfile. +Saved as the ``hash_dist`` metric in a statsfile. Examples ------------------------------------------------------------------------ + ``scenedetect -i video.mp4 detect-hash`` ``scenedetect -i video.mp4 detect-hash --size 32 --lowpass 3`` @@ -297,17 +307,18 @@ Examples Options ------------------------------------------------------------------------ + .. option:: -t VAL, --threshold VAL 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 @@ -317,7 +328,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global min-scene-len (-m) setting. TIMECODE can be specified as exact number of frames, a time in seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. .. _command-detect-hist: @@ -332,12 +343,13 @@ Find fast cuts by differencing YUV histograms. Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. -Saved as the `hist_diff` metric in a statsfile. +Saved as the ``hist_diff`` metric in a statsfile. Examples ------------------------------------------------------------------------ + ``scenedetect -i video.mp4 detect-hist`` ``scenedetect -i video.mp4 detect-hist --threshold 0.1 --bins 240`` @@ -346,21 +358,22 @@ Examples Options ------------------------------------------------------------------------ + .. option:: -t VAL, --threshold VAL 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 + The number of bins to use for the histogram calculation. - Default: ``16`` + Default: ``128`` .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global min-scene-len (-m) setting. TIMECODE can be specified as exact number of frames, a time in seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. .. _command-detect-threshold: @@ -371,7 +384,7 @@ Options ``detect-threshold`` ======================================================================== -Perform threshold detection algorithm on input video. +Find fade in/out using averaging. Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. @@ -409,7 +422,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). ************************************************************************ @@ -417,49 +430,28 @@ Commands ************************************************************************ -.. _command-export-html: +.. _command-list-scenes: -.. program:: scenedetect export-html +.. program:: scenedetect list-scenes -``export-html`` +``list-scenes`` ======================================================================== -Export scene list to HTML file. Requires save-images unless --no-images is specified. +Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). -Options +Examples ------------------------------------------------------------------------ -.. option:: -f NAME, --filename NAME +Default: - 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. + ``scenedetect -i video.mp4 list-scenes`` - Default: ``$VIDEO_NAME-Scenes.html`` - -.. option:: --no-images - - Export the scene list including or excluding the saved images. - -.. option:: -w pixels, --image-width pixels - - Width in pixels of the images in the resulting HTML table. - -.. option:: -h pixels, --image-height pixels - - Height in pixels of the images in the resulting HTML table. +Without cut list (RFC 4180 compliant CSV): - -.. _command-list-scenes: - -.. program:: scenedetect list-scenes - - -``list-scenes`` -======================================================================== - -Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). + ``scenedetect -i video.mp4 list-scenes --skip-cuts`` Options @@ -468,11 +460,11 @@ Options .. option:: -o DIR, --output DIR - Output directory to save videos to. Overrides global option :option:`-o/--output ` if set. + Output directory to save videos to. Overrides global option :option:`-o/--output `. .. option:: -f NAME, --filename NAME - 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. :option:`-f=\$VIDEO_NAME-Scenes.csv <-f>`). + 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). Default: ``$VIDEO_NAME-Scenes.csv`` @@ -524,6 +516,120 @@ Options Default: ``"Start Frame"`` +.. _command-save-edl: + +.. program:: scenedetect save-edl + + +``save-edl`` +======================================================================== + +Save cuts in EDL format (CMX 3600). + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.edl`` + +.. option:: -t NAME, --title NAME + + Title format to use. + + Default: ``$VIDEO_NAME`` + +.. option:: -r REEL, --reel REEL + + Reel name to use. + + Default: ``AX`` + +.. option:: -o DIR, --output DIR + + 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: + +.. program:: scenedetect save-html + + +``save-html`` +======================================================================== + +Save scene list to HTML file. + +To customize image generation, specify the :ref:`save-images ` command before :ref:`save-html `. This command always uses the result of the preceeding :ref:`save-images ` command, or runs it with the default config values unless ``--no-images`` is set. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + 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. + + Default: ``$VIDEO_NAME-Scenes.html`` + +.. option:: -n, --no-images + + Do not include images with the result. + +.. option:: -w pixels, --image-width pixels + + Width in pixels of the images in the resulting HTML table. + +.. option:: -h pixels, --image-height pixels + + Height in pixels of the images in the resulting HTML table. + +.. option:: -s, --show + + Automatically open resulting HTML when processing is complete. + + .. _command-save-images: .. program:: scenedetect save-images @@ -532,16 +638,14 @@ Options ``save-images`` ======================================================================== -Create images for each detected scene. - -Images can be resized +Save images from each detected scene. Examples ------------------------------------------------------------------------ - ``scenedetect -i video.mp4 save-images`` + ``scenedetect -i video.mp4 save-images --num-images 5`` ``scenedetect -i video.mp4 save-images --width 1024`` @@ -554,17 +658,17 @@ Options .. option:: -o DIR, --output DIR - Output directory for images. Overrides global option :option:`-o/--output ` if set. + Output directory for images. Overrides global option :option:`-o/--output `. .. option:: -f NAME, --filename NAME - 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. :option:`-f=\$SCENE_NUMBER-Image-\$IMAGE_NUMBER <-f>`) or single quotes. + 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. Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER`` .. option:: -n N, --num-images N - Number of images to generate per scene. Will always include start/end frame, unless :option:`-n=1 <-n>`, in which case the image will be the frame at the mid-point of the scene. + 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. Default: ``3`` @@ -592,11 +696,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 @@ -611,6 +715,80 @@ Options Width (pixels) of images. +.. _command-save-otio: + +.. program:: scenedetect save-otio + + +``save-otio`` +======================================================================== + +Save cuts as an OTIO timeline. + +Uses the Timeline.1 schema. OTIO (OpenTimelineIO) timelines can be imported by many video editors. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.otio`` + +.. option:: -n NAME, --name NAME + + Name of timeline to use. + + Default: ``"$VIDEO_NAME (PySceneDetect)"`` + +.. option:: -o DIR, --output DIR + + Output directory to save OTIO file to. Overrides global option :option:`-o/--output `. + +.. option:: --audio + + Include audio track (default). + +.. option:: --no-audio + + Exclude audio track. + + +.. _command-save-qp: + +.. program:: scenedetect save-qp + + +``save-qp`` +======================================================================== + +Save cuts as keyframes (I-frames) for video encoding. + +The resulting QP file can be used with the ``--qpfile`` argument in x264/x265. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.qp`` + +.. option:: -o DIR, --output DIR + + Output directory to save QP file to. Overrides global option :option:`-o/--output `. + +.. option:: -d, --disable-shift + + Disable shifting frame numbers by start time. + + .. _command-split-video: .. program:: scenedetect split-video @@ -626,10 +804,16 @@ Examples ------------------------------------------------------------------------ +Default: + ``scenedetect -i video.mp4 split-video`` +Codec-copy mode (not frame accurate): + ``scenedetect -i video.mp4 split-video --copy`` +Customized filenames: + ``scenedetect -i video.mp4 split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER`` @@ -639,11 +823,11 @@ Options .. option:: -o DIR, --output DIR - Output directory to save videos to. Overrides global option :option:`-o/--output ` if set. + Output directory to save videos to. Overrides global option :option:`-o/--output `. .. option:: -f NAME, --filename NAME - 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. :option:`-f=\$VIDEO_NAME-Scene-\$SCENE_NUMBER <-f>`). + 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). Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER`` @@ -653,7 +837,7 @@ Options .. option:: -c, --copy - Copy instead of re-encode. Faster but less precise. Equivalent to: :option:`--args="-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" <--args>` + Copy instead of re-encode. Faster but less precise. .. option:: -hq, --high-quality @@ -681,6 +865,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: @@ -692,11 +880,11 @@ Options Set start/end/duration of input video. -Values can be specified as frames (NNNN), seconds (NNNN.NNs), or timecode (HH:MM:SS.nnn or MM:SS.nnn). For example, to process only the first minute of a video: +Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - ``scenedetect -i video.mp4 time --end 1:00`` + ``scenedetect -i video.mp4 time --end 00:01:00`` - ``scenedetect -i video.mp4 time --duration 60s`` + ``scenedetect -i video.mp4 time --duration 60.0`` Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: @@ -709,7 +897,7 @@ Options .. option:: -s TIMECODE, --start TIMECODE - Time in video to start detection. TIMECODE can be specified as number of frames (:option:`--start=100 <--start>` for frame 100), time in seconds (:option:`--start=100.0 <--start>` for 100 seconds), or timecode (:option:`--start=00:01:40 <--start>` for 1m40s). + Time in video to start detection. TIMECODE can be specified as seconds (:option:`--start=100.0 <--start>`), frames (:option:`--start=100 <--start>`), or timecode (:option:`--start=00:01:40.000 <--start>`). .. option:: -d TIMECODE, --duration TIMECODE diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst index 8df76a58..2e28102b 100644 --- a/docs/cli/backends.rst +++ b/docs/cli/backends.rst @@ -21,6 +21,8 @@ It is mostly reliable and fast, although can occasionally run into issues proces The OpenCV backend also supports image sequences as inputs (e.g. ``frame%02d.jpg`` if you want to load frame001.jpg, frame002.jpg, frame003.jpg...). Make sure to specify the framerate manually (``-f``/``--framerate``) to ensure accurate timing calculations. +Variable framerate (VFR) video is supported. Scene detection uses PTS-derived timestamps from ``CAP_PROP_POS_MSEC`` for accurate timecodes. Seeking compensates for OpenCV's average-fps-based internal seek approximation, so output timecodes remain accurate across the full video. + ======================================================================= PyAV @@ -28,6 +30,8 @@ PyAV The `PyAV `_ backend (`av package `_) is a more robust backend that handles multiple audio tracks and frame decode errors gracefully. +Variable framerate (VFR) video is fully supported. PyAV uses native PTS timestamps directly from the container, giving the most accurate timecodes for VFR content. + This backend can be used by specifying ``-b pyav`` via command line, or setting ``backend = pyav`` under the ``[global]`` section of your :ref:`config file `. @@ -41,4 +45,6 @@ MoviePy launches ffmpeg as a subprocess, and can be used with various types of i The MoviePy backend is still under development and is not included with current Windows distribution. To enable MoviePy support, you must install PySceneDetect using `python` and `pip`. + Variable framerate (VFR) video is **not supported**. MoviePy assumes a fixed framerate, so timecodes for VFR content will be inaccurate. Use the PyAV or OpenCV backend instead. + This backend can be used by specifying ``-b moviepy`` via command line, or setting ``backend = moviepy`` under the ``[global]`` section of your :ref:`config file `. diff --git a/docs/conf.py b/docs/conf.py index 60ad6188..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,6 +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 = "_static/favicon.ico" # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -148,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 f2c85c5d..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,13 +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[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), ] @@ -267,6 +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 + ) with open("docs/cli.rst", "wb") as f: f.write(help.encode()) diff --git a/docs/index.rst b/docs/index.rst index 1aca59b1..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:: @@ -46,14 +46,15 @@ Table of Contents api api/detectors + api/output api/backends + api/common api/scene_manager - api/video_splitter - api/stats_manager - api/frame_timecode - api/scene_detector + api/detector 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 a367e367..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Sphinx == 7.0.1 -opencv-python -numpy -av 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/packaging/logo/pyscenedetect-24.svg b/packaging/logo/pyscenedetect-24.svg new file mode 100644 index 00000000..9eb62d50 --- /dev/null +++ b/packaging/logo/pyscenedetect-24.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + diff --git a/packaging/logo/pyscenedetect-32.svg b/packaging/logo/pyscenedetect-32.svg new file mode 100644 index 00000000..a6e64900 --- /dev/null +++ b/packaging/logo/pyscenedetect-32.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + diff --git a/packaging/logo/pyscenedetect-logo-bg.svg b/packaging/logo/pyscenedetect-logo-bg.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/packaging/logo/pyscenedetect-logo-bg.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/packaging/logo/pyscenedetect-logo.svg b/packaging/logo/pyscenedetect-logo.svg new file mode 100644 index 00000000..22cbc48c --- /dev/null +++ b/packaging/logo/pyscenedetect-logo.svg @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + 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/packaging/logo/pyscenedetect.svg b/packaging/logo/pyscenedetect.svg new file mode 100644 index 00000000..ad5c55e2 --- /dev/null +++ b/packaging/logo/pyscenedetect.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + 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/packaging/windows/installer/license65.dat.enc b/packaging/windows/installer/license65.dat.enc new file mode 100644 index 00000000..d3d500e1 Binary files /dev/null and b/packaging/windows/installer/license65.dat.enc differ 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 2c45e1a5..00000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# -# PySceneDetect Requirements -# -av>=9.2 -click>=8.0 -numpy -opencv-python -platformdirs -pytest>=7.0 -tqdm diff --git a/requirements_headless.txt b/requirements_headless.txt deleted file mode 100644 index 4dfedd38..00000000 --- a/requirements_headless.txt +++ /dev/null @@ -1,10 +0,0 @@ -# -# PySceneDetect Requirements for Headless Machines -# -av>=9.2 -click>=8.0 -numpy -opencv-python-headless -platformdirs -pytest>=7.0 -tqdm diff --git a/scenedetect.cfg b/scenedetect.cfg index bde791de..d987435e 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -22,22 +22,24 @@ # [global] -# Output directory for written files. If unset, defaults to working directory. -#output = /usr/tmp/scenedetect/ # Default detector to use. # Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist #default-detector = detect-adaptive -# Video backend interface, must be one of: opencv, pyav. -#backend = opencv +# Output directory for written files. Defaults to working directory. +#output = /usr/tmp/scenedetect/ -# Downscale frame using a ratio of N. Set to 1 for no downscaling. If unset, -# applied automatically based on input video resolution. Must be an integer value. -#downscale = 1 +# Verbosity of console output (debug, info, warning, error, or none). +# Set to none for the same behavior as specifying -q/--quiet. +#verbosity = debug -# Method to use for downscaling (nearest, linear, cubic, area, lanczos4). -#downscale-method = linear +# Crop input video to area. Specified as two points in the form X0 Y0 X1 Y1 or +# as (X0 Y0), (X1 Y1). Coordinate (0, 0) is the top-left corner. +#crop = 100 100 200 250 + +# Video backend interface, must be one of: opencv, pyav, moviepy. +#backend = opencv # Minimum length of a given scene. #min-scene-len = 0.6s @@ -49,9 +51,12 @@ # Drop scenes shorter than min-scene-len instead of merging (yes/no). #drop-short-scenes = no -# Verbosity of console output (debug, info, warning, error, or none). -# Set to none for the same behavior as specifying -q/--quiet. -#verbosity = debug +# Downscale frame before processing. Set to 1 for no downscaling. +# By default, downscale will be calculated automatically. +#downscale = 1 + +# Method to use for downscaling (nearest, linear, cubic, area, lanczos4). +#downscale-method = linear # Amount of frames to skip between performing scene detection. Not recommended. #frame-skip = 0 @@ -121,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, @@ -130,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 @@ -140,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 @@ -201,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. @@ -219,27 +229,34 @@ # Image quality (jpeg/webp). Default is 95 for jpeg, 100 for webp #quality = 95 -# Compression amount for png images (0 to 9). Does not affect quality. +# Compression amount for png images (0 to 9). Only affects size, not quality. #compression = 3 -# Number of frames to skip at beginning/end of scene. +# 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 -# Factor to resize images by (0.5 = half, 1.0 = same, 2.0 = double). +# Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double). #scale = 1.0 -# Override image height and/or width. Mutually exclusive with scale. +# Resize to specified height, width, or both. Mutually exclusive with scale. #height = 0 #width = 0 -# Method to use for image scaling (nearest, linear, cubic, area, lanczos4). +# Method to use for scaling (nearest, linear, cubic, area, lanczos4). #scale-method = linear +# Use separate threads for encoding and disk IO. Can improve performance. +#threading = yes -[export-html] + +[save-html] # Filename format of created HTML file. Can use $VIDEO_NAME in the name. #filename = $VIDEO_NAME-Scenes.html +# Automatically open resulting HTML when processing is complete. +#show = no + # Override element width/height. #image-height = 0 #image-width = 0 @@ -266,6 +283,14 @@ # Display list of cut points generated from scene boundaries (yes/no). #display-cuts = yes +# Separator to use between columns in output file. Must be single (escaped) +# ASCII character. +#col-separator = , + +# Separator to use between rows in output file. Must be (escaped) ASCII +# characters. +#row-separator = \n + # Format to use for list of cut points (frames, seconds, timecode). #cut-format = timecode @@ -283,6 +308,62 @@ #start-col-name = Start Frame +[save-edl] + +# Filename format of EDL file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.edl + +# Folder to output EDL file to. Overrides [global] output option. +#output = /usr/tmp/images + +# Reel/tape name to use. +#reel = AX + +# Title to use for the EDL information. Can use $VIDEO_NAME macro. +#title = $VIDEO_NAME (PySceneDetect) + + +[save-otio] + +# Filename format of OTIO file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.otio + +# Folder to output OTIO file to. Overrides [global] output option. +#output = /usr/tmp/images + +# Name to use for the OTIO timeline. Can use $VIDEO_NAME macro. +#title = $VIDEO_NAME (PySceneDetect) + +# Include audio track (yes/no). +#audio = yes + + +[save-qp] + +# Filename format of QP file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.qp + +# Folder to output QP file to. Overrides [global] output option. +#output = /usr/tmp/images + +# Disable shifting frame numbers by start time (yes/no). +#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 544be977..ad2dc8dc 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. # @@ -16,13 +16,12 @@ """ from logging import getLogger -from typing import List, Optional, Tuple, Union # 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", @@ -30,52 +29,82 @@ ) from ex # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. -from scenedetect.platform import init_logger # noqa: I001 -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.video_stream import VideoStream, VideoOpenFailure -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge -from scenedetect.scene_detector import SceneDetector +# Note that order of importants is important! +from scenedetect.platform import init_logger as init_logger # noqa: I001 +from scenedetect.common import ( + 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.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 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 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.scene_manager import SceneManager, save_images -from scenedetect.video_manager import VideoManager # [DEPRECATED] DO NOT USE. +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.6.5-dev1" +__version__ = "0.7.1" init_logger() logger = getLogger("pyscenedetect") def open_video( - path: str, - framerate: 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. @@ -86,13 +115,21 @@ 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 + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is + # used, once internal callers and downstream users have had a release to migrate. + if 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: @@ -104,7 +141,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: @@ -115,18 +152,21 @@ def open_video( def detect( - video_path: str, + video_path: "StrPath | list[StrPath] | tuple[StrPath, ...]", detector: SceneDetector, - stats_file_path: Optional[str] = None, + stats_file_path: StrPath | None = None, show_progress: bool = False, - start_time: Optional[Union[str, float, int]] = None, - end_time: Optional[Union[str, float, int]] = None, + start_time: TimecodeLike | None = None, + end_time: TimecodeLike | None = None, start_in_scene: bool = False, -) -> List[Tuple[FrameTimecode, FrameTimecode]]: + 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 @@ -141,9 +181,13 @@ 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 (pairs of :class:`FrameTimecode` objects). + List of scenes as pairs of (start, end) :class:`FrameTimecode` objects. Raises: :class:`VideoOpenFailure`: `video_path` could not be opened. @@ -151,12 +195,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) @@ -164,8 +206,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 208e4d1d..dd21768c 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,11 +22,11 @@ import logging import os import os.path -import typing as ty +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, @@ -34,7 +34,7 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, - TimecodeFormat, + RangeValue, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS @@ -46,22 +46,33 @@ ThresholdDetector, ) from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info -from scenedetect.scene_manager import Interpolation -_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 +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 = """ +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/ ]. @@ -90,22 +101,22 @@ """ -class _Command(click.Command): +class Command(click.Command): """Custom formatting for commands.""" 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(click.style(LINE_SEPARATOR, fg="cyan")) formatter.write_paragraph() else: - formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) + formatter.write(click.style(LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() formatter.write(click.style("PySceneDetect Help", fg="yellow")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) + formatter.write(click.style(LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() self.format_usage(ctx, formatter) @@ -118,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() @@ -132,13 +143,13 @@ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> N formatter.write_text(epilog) -class _CommandGroup(_Command, click.Group): +class CommandGroup(Command, click.Group): """Custom formatting for command groups.""" pass -def _print_command_help(ctx: click.Context, command: click.Command): +def print_command_help(ctx: click.Context, command: click.Command): """Print help/usage for a given command. Modifies `ctx` in-place.""" ctx.info_name = command.name ctx.command = command @@ -146,12 +157,40 @@ def _print_command_help(ctx: click.Context, command: click.Command): click.echo(command.get_help(ctx)) +SCENEDETECT_COMMAND_HELP = """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: + + {scenedetect_with_video} [detector] [commands] + +For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. + +Examples: + +Split video wherever a new scene is detected: + + {scenedetect_with_video} split-video + +Save scene list in CSV format with images at the start, middle, and end of each scene: + + {scenedetect_with_video} list-scenes save-images + +Skip the first 10 seconds of the input video: + + {scenedetect_with_video} time --start 10s detect-content + +Show summary of all options and commands: + + {scenedetect} --help + +Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once.""" + + @click.group( - cls=_CommandGroup, + cls=CommandGroup, chain=True, context_settings=dict(help_option_names=["-h", "--help"]), invoke_without_command=True, epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""", + help=SCENEDETECT_COMMAND_HELP, ) # *NOTE*: Although input is required, we cannot mark it as `required=True`, otherwise we will reject # commands of the form `scenedetect detect-content --help`. @@ -171,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", @@ -189,12 +229,22 @@ 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( - "--framerate", + "--frame-rate", "-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( + "--framerate", + "framerate_legacy", + metavar="FPS", + type=click.FLOAT, + default=None, + hidden=True, + help="[DEPRECATED] Use -f/--frame-rate instead.", ) @click.option( "--min-scene-len", @@ -202,22 +252,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, - help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" - % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), + default=None, + 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, - help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" - % (USER_CONFIG.get_help_string("global", "merge-last-scene")), + default=None, + 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", @@ -225,8 +280,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).{}".format( + USER_CONFIG.get_help_string("global", "crop", show_default=False) + ), ) @click.option( "--downscale", @@ -234,8 +299,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", @@ -243,8 +309,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", @@ -252,8 +319,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"), ), @@ -275,61 +341,45 @@ 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: bool, - merge_last_scene: bool, - backend: ty.Optional[str], - 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, + framerate_legacy: 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, ): - """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: - - {scenedetect_with_video} [detector] [commands] - - For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. - - Examples: - - Split video wherever a new scene is detected: - - {scenedetect_with_video} split-video - - Save scene list in CSV format with images at the start, middle, and end of each scene: - - {scenedetect_with_video} list-scenes save-images - - Skip the first 10 seconds of the input video: - - {scenedetect_with_video} time --start 10s detect-content - - Show summary of all options and commands: - - {scenedetect} --help - - Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. - """ ctx = ctx.obj assert isinstance(ctx, CliContext) + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `--framerate` + # is used, once downstream users have had a release to migrate to `--frame-rate`. + if frame_rate is None: + frame_rate = framerate_legacy + elif framerate_legacy is not None: + logger.warning("Both --frame-rate and --framerate were specified; using --frame-rate.") + ctx.handle_options( input_path=input, output=output, - framerate=framerate, + frame_rate=frame_rate, stats_file=stats, - downscale=downscale, frame_skip=frame_skip, min_scene_len=min_scene_len, drop_short_scenes=drop_short_scenes, merge_last_scene=merge_last_scene, backend=backend, + crop=crop, + downscale=downscale, quiet=quiet, logfile=logfile, config=config, @@ -338,7 +388,16 @@ def scenedetect( ) -@click.command("help", cls=_Command) +def add_hidden_alias(command: click.Command, alias: str): + """Adds a copy of `command` that can be invoked under the name `alias`.""" + # 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) + + +@click.command("help", cls=Command) @click.argument( "command_name", required=False, @@ -346,39 +405,45 @@ def scenedetect( ) @click.pass_context def help_command(ctx: click.Context, command_name: str): - """Print help for command (`help [command]`).""" - assert isinstance(ctx.parent.command, click.MultiCommand) + """Print full help reference.""" + # TODO: Other commands still seem to run if this is specified. + 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() -@click.command("about", cls=_Command, add_help_option=False) +@click.command("about", cls=Command, add_help_option=False) @click.pass_context 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(_LINE_SEPARATOR, fg="cyan")) - click.echo(_ABOUT_STRING) + click.echo(click.style(LINE_SEPARATOR, fg="cyan")) + 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() -@click.command("version", cls=_Command, add_help_option=False) +@click.command("version", cls=Command, add_help_option=False) @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" @@ -387,7 +452,21 @@ def version_command(ctx: click.Context): ctx.exit() -@click.command("time", cls=_Command) +TIME_COMMAND_HELP = """Set start/end/duration of input video. + +Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: + + {scenedetect_with_video} time --end 00:01:00 + + {scenedetect_with_video} time --duration 60.0 + +Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: + + {scenedetect_with_video} time --start 0 --end 1000 +""" + + +@click.command("time", cls=Command, help=TIME_COMMAND_HELP) @click.option( "--start", "-s", @@ -415,22 +494,10 @@ 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, ): - """Set start/end/duration of input video. - - Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - - {scenedetect_with_video} time --end 00:01:00 - - {scenedetect_with_video} time --duration 60.0 - - Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: - - {scenedetect_with_video} time --start 0 --end 1000 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -450,18 +517,40 @@ def time_command( raise click.BadParameter("-e/--end time must be greater than -s/--start") -@click.command("detect-content", cls=_Command) +DETECT_CONTENT_HELP = """Find fast cuts using differences in HSL (filtered). + +For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. + +Scores are calculated from several components which are also recorded in the statsfile: + + - *delta_hue*: Difference between pixel hue values of adjacent frames. + + - *delta_sat*: Difference between pixel saturation values of adjacent frames. + + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + +Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. + +Examples: + + {scenedetect_with_video} detect-content + + {scenedetect_with_video} detect-content --threshold 27.5 +""" + + +@click.command("detect-content", cls=Command, help=DETECT_CONTENT_HELP) @click.option( "--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", @@ -469,16 +558,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", @@ -486,8 +577,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", @@ -508,8 +600,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"), ), @@ -517,35 +608,13 @@ 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, ): - """Find fast cuts using differences in HSL (filtered). - - For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. - - Scores are calculated from several components which are also recorded in the statsfile: - - - *delta_hue*: Difference between pixel hue values of adjacent frames. - - - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. - - Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. - - Examples: - - {scenedetect_with_video} detect-content - - {scenedetect_with_video} detect-content --threshold 27.5 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_content_params( @@ -559,15 +628,28 @@ def detect_content_command( ctx.add_detector(ContentDetector, detector_args) -@click.command("detect-adaptive", cls=_Command) +DETECT_ADAPTIVE_HELP = """Find fast cuts using diffs in HSL colorspace (rolling average). + +Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. + +Examples: + + {scenedetect_with_video} detect-adaptive + + {scenedetect_with_video} detect-adaptive --threshold 3.2 +""" + + +@click.command("detect-adaptive", cls=Command, help=DETECT_ADAPTIVE_HELP) @click.option( "--threshold", "-t", 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", @@ -575,18 +657,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")), -) -@click.option( - "--min-delta-hsv", - "-d", - metavar="VAL", - type=click.FLOAT, - default=None, - help="[DEPRECATED] Use -c/--min-content-val instead.%s" - % (USER_CONFIG.get_help_string("detect-adaptive", "min-delta-hsv")), - hidden=True, + 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", @@ -594,24 +667,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", @@ -619,8 +695,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", @@ -628,7 +705,7 @@ def detect_content_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778).%s" % ( "" if USER_CONFIG.is_default("detect-adaptive", "min-scene-len") @@ -638,31 +715,19 @@ def detect_content_command( @click.pass_context def detect_adaptive_command( ctx: click.Context, - threshold: ty.Optional[float], - min_content_val: ty.Optional[float], - min_delta_hsv: 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, ): - """Find fast cuts using diffs in HSL colorspace (rolling average). - - Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. - - Examples: - - {scenedetect_with_video} detect-adaptive - - {scenedetect_with_video} detect-adaptive --threshold 3.2 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_adaptive_params( threshold=threshold, min_content_val=min_content_val, - min_delta_hsv=min_delta_hsv, frame_window=frame_window, luma_only=luma_only, min_scene_len=min_scene_len, @@ -672,38 +737,47 @@ def detect_adaptive_command( ctx.add_detector(AdaptiveDetector, detector_args) -@click.command("detect-threshold", cls=_Command) +DETECT_THRESHOLD_HELP = """Find fade in/out using averaging. + +Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. + +Examples: + + {scenedetect_with_video} detect-threshold + + {scenedetect_with_video} detect-threshold --threshold 15 +""" + + +@click.command("detect-threshold", cls=Command, help=DETECT_THRESHOLD_HELP) @click.option( "--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", @@ -711,7 +785,7 @@ def detect_adaptive_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778).%s" % ( "" if USER_CONFIG.is_default("detect-threshold", "min-scene-len") @@ -721,21 +795,11 @@ 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, ): - """Find fade in/out using averaging. - - Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. - - Examples: - - {scenedetect_with_video} detect-threshold - - {scenedetect_with_video} detect-threshold --threshold 15 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_threshold_params( @@ -747,30 +811,41 @@ def detect_threshold_command( ctx.add_detector(ThresholdDetector, detector_args) -@click.command("detect-hist", cls=_Command) +DETECT_HIST_HELP = """Find fast cuts by differencing YUV histograms. + +Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. + +Saved as the `hist_diff` metric in a statsfile. + +Examples: + + {scenedetect_with_video} detect-hist + + {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 +""" + + +@click.command("detect-hist", cls=Command, help=DETECT_HIST_HELP) @click.option( "--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", @@ -790,22 +865,10 @@ 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, ): - """Find fast cuts by differencing YUV histograms. - - Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. - - Saved as the `hist_diff` metric in a statsfile. - - Examples: - - {scenedetect_with_video} detect-hist - - {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_hist_params( @@ -814,44 +877,55 @@ def detect_hist_command( ctx.add_detector(HistogramDetector, detector_args) -@click.command("detect-hash", cls=_Command) +DETECT_HASH_HELP = """Find fast cuts using perceptual hashing. + +The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. + +Saved as the `hash_dist` metric in a statsfile. + +Examples: + + {scenedetect_with_video} detect-hash + + {scenedetect_with_video} detect-hash --size 32 --lowpass 3 +""" + + +@click.command("detect-hash", cls=Command, help=DETECT_HASH_HELP) @click.option( "--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( @@ -872,23 +946,11 @@ 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, ): - """Find fast cuts using perceptual hashing. - - The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. - - Saved as the `hash_dist` metric in a statsfile. - - Examples: - - {scenedetect_with_video} detect-hash - - {scenedetect_with_video} detect-hash --size 32 --lowpass 3 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_hash_params( @@ -897,7 +959,17 @@ def detect_hash_command( ctx.add_detector(HashDetector, detector_args) -@click.command("load-scenes", cls=_Command) +LOAD_SCENES_HELP = """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). + +Examples: + + {scenedetect_with_video} load-scenes -i scenes.csv + + {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" +""" + + +@click.command("load-scenes", cls=Command, help=LOAD_SCENES_HELP) @click.option( "--input", "-i", @@ -913,29 +985,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] -): - """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). - - Examples: - - {scenedetect_with_video} load-scenes -i scenes.csv - - {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" - """ +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( @@ -947,72 +1013,112 @@ def load_scenes_command( ) -@click.command("export-html", cls=_Command) +SAVE_HTML_HELP = """Save scene list to HTML file. + +To customize image generation, specify the `save-images` command before `save-html`. This command always uses the result of the preceeding `save-images` command, or runs it with the default config values unless `--no-images` is set. +""" + + +@click.command("save-html", cls=Command, help=SAVE_HTML_HELP) @click.option( "--filename", "-f", 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("export-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="Export the scene list including or excluding the saved images.%s" - % (USER_CONFIG.get_help_string("export-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("export-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("export-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", + "-s", + is_flag=True, + flag_value=True, + default=None, + help="Automatically open resulting HTML when processing is complete.{}".format( + USER_CONFIG.get_help_string("save-html", "show") + ), ) @click.pass_context -def export_html_command( +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, ): - """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" + if ctx.info_name == "export-html": + logger.warning("WARNING: export-html is deprecated, use save-html instead.") ctx = ctx.obj assert isinstance(ctx, CliContext) - - no_images = no_images or ctx.config.get_value("export-html", "no-images") - if not ctx.save_images and not no_images: - raise click.BadArgumentUsage( - "export-html requires that save-images precedes it or --no-images is specified." - ) - export_html_args = { - "html_name_format": ctx.config.get_value("export-html", "filename", filename), - "image_width": ctx.config.get_value("export-html", "image-width", image_width), - "image_height": ctx.config.get_value("export-html", "image-height", image_height), + # Make sure a save-images command is in the pipeline for us to use the results from if we need + # 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), + "image_width": ctx.config.get_value("save-html", "image-width", image_width), + "image_height": ctx.config.get_value("save-html", "image-height", image_height), + "no_images": ctx.config.get_value("save-html", "no-images", no_images), + "show": ctx.config.get_value("save-html", "show", show), } - ctx.add_command(cli_commands.export_html, export_html_args) + ctx.add_command(cli_commands.save_html, save_html_args) + + +LIST_SCENES_HELP = """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). + +Examples: + +Default: + + {scenedetect_with_video} list-scenes + +Without cut list (RFC 4180 compliant CSV): + + {scenedetect_with_video} list-scenes --skip-cuts +""" -@click.command("list-scenes", cls=_Command) +@click.command("list-scenes", cls=Command, help=LIST_SCENES_HELP) @click.option( "--output", "-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 if set.%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", @@ -1020,69 +1126,94 @@ def export_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", "-n", is_flag=True, flag_value=True, - help="Only print scene list.%s" - % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), + default=None, + help="Only print scene list.{}".format( + USER_CONFIG.get_help_string("list-scenes", "no-output-file") + ), ) @click.option( "--quiet", "-q", is_flag=True, flag_value=True, - help="Suppress printing scene list.%s" % (USER_CONFIG.get_help_string("list-scenes", "quiet")), + default=None, + help="Suppress printing scene list.{}".format( + USER_CONFIG.get_help_string("list-scenes", "quiet") + ), ) @click.option( "--skip-cuts", "-s", is_flag=True, flag_value=True, - 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")), + default=None, + 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: bool, - quiet: bool, - skip_cuts: bool, + output: str | None, + filename: str | None, + no_output_file: bool | None, + quiet: bool | None, + skip_cuts: bool | None, ): - """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default).""" ctx = ctx.obj assert isinstance(ctx, CliContext) - no_output_file = no_output_file or ctx.config.get_value("list-scenes", "no-output-file") - scene_list_dir = ctx.config.get_value("list-scenes", "output", output) - scene_list_name_format = ctx.config.get_value("list-scenes", "filename", filename) list_scenes_args = { - "cut_format": TimecodeFormat[ctx.config.get_value("list-scenes", "cut-format").upper()], + "col_separator": ctx.config.get_value("list-scenes", "col-separator"), + "cut_format": ctx.config.get_value("list-scenes", "cut-format"), "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), - "scene_list_output": not no_output_file, - "scene_list_name_format": scene_list_name_format, - "skip_cuts": skip_cuts or ctx.config.get_value("list-scenes", "skip-cuts"), - "output_dir": scene_list_dir, - "quiet": quiet or ctx.config.get_value("list-scenes", "quiet") or ctx.quiet_mode, + "no_output_file": ctx.config.get_value("list-scenes", "no-output-file", no_output_file), + "filename": ctx.config.get_value("list-scenes", "filename", filename), + "skip_cuts": ctx.config.get_value("list-scenes", "skip-cuts", skip_cuts), + "output": ctx.config.get_value("list-scenes", "output", output), + "quiet": ctx.config.get_value("list-scenes", "quiet", quiet) or ctx.quiet_mode, + "row_separator": ctx.config.get_value("list-scenes", "row-separator"), } ctx.add_command(cli_commands.list_scenes, list_scenes_args) -@click.command("split-video", cls=_Command) +SPLIT_VIDEO_HELP = """Split input video using ffmpeg or mkvmerge. + +Examples: + +Default: + + {scenedetect_with_video} split-video + +Codec-copy mode (not frame accurate): + + {scenedetect_with_video} split-video --copy + +Customized filenames: + + {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER +""" + + +@click.command("split-video", cls=Command, help=SPLIT_VIDEO_HELP) @click.option( "--output", "-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 if set.%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", @@ -1090,44 +1221,47 @@ 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", "-q", is_flag=True, flag_value=True, - help="Hide output from external video splitting tool.%s" - % (USER_CONFIG.get_help_string("split-video", "quiet")), + default=False, + 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", @@ -1135,8 +1269,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"), ), @@ -1147,51 +1280,53 @@ 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, ): - """Split input video using ffmpeg or mkvmerge. - - Examples: - - {scenedetect_with_video} split-video - - {scenedetect_with_video} split-video --copy - - {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER - """ 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") - # We only load the config values for these flags/options if none of the other - # encoder flags/options were set via the CLI to avoid any conflicting options - # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). + # Overwrite flags if no encoder flags/options were set via the CLI to avoid conflicting options + # (e.g. `--copy` should override any `high-quality = yes` setting in the config file). if not (mkvmerge or copy or high_quality or args or rate_factor or preset): mkvmerge = ctx.config.get_value("split-video", "mkvmerge") copy = ctx.config.get_value("split-video", "copy") @@ -1205,20 +1340,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 @@ -1243,21 +1378,35 @@ def split_video_command( split_video_args = { "name_format": ctx.config.get_value("split-video", "filename", filename), "use_mkvmerge": mkvmerge, - "output_dir": ctx.config.get_value("split-video", "output", output), - "show_output": not quiet, + "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) -@click.command("save-images", cls=_Command) +SAVE_IMAGES_HELP = """Save images from each detected scene. + +Examples: + + {scenedetect_with_video} save-images --num-images 5 + + {scenedetect_with_video} save-images --width 1024 + + {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER +""" + + +@click.command("save-images", cls=Command, help=SAVE_IMAGES_HELP) @click.option( "--output", "-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 if set.%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", @@ -1265,8 +1414,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", @@ -1274,16 +1424,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", @@ -1298,8 +1450,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", @@ -1314,17 +1467,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", @@ -1332,8 +1487,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", @@ -1341,8 +1497,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", @@ -1350,39 +1507,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], - filename: ty.Optional[ty.AnyStr], - num_images: ty.Optional[int], - jpeg: bool, - webp: bool, - quality: ty.Optional[int], - png: bool, - compression: ty.Optional[int], - frame_margin: ty.Optional[int], - scale: ty.Optional[float], - height: ty.Optional[int], - width: ty.Optional[int], + output: str | None = None, + filename: str | None = None, + num_images: int | None = None, + jpeg: bool = False, + webp: bool = False, + quality: int | None = None, + png: bool = False, + compression: int | None = None, + frame_margin: str | None = None, + scale: float | None = None, + height: int | None = None, + width: int | None = None, ): - """Create images for each detected scene. - - Images can be resized - - Examples: - - {scenedetect_with_video} save-images - - {scenedetect_with_video} save-images --width 1024 - - {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER - """ 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." @@ -1402,7 +1549,7 @@ def save_images_command( scale = ctx.config.get_value("save-images", "scale") height = ctx.config.get_value("save-images", "height") width = ctx.config.get_value("save-images", "width") - scale_method = Interpolation[ctx.config.get_value("save-images", "scale-method").upper()] + scale_method = ctx.config.get_value("save-images", "scale-method") quality = ( (DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY) if ctx.config.is_default("save-images", "quality") @@ -1413,7 +1560,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. ", @@ -1427,45 +1574,292 @@ def save_images_command( "frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin), "height": height, "image_extension": image_extension, - "image_name_template": ctx.config.get_value("save-images", "filename", filename), + "filename": ctx.config.get_value("save-images", "filename", filename), "interpolation": scale_method, "num_images": ctx.config.get_value("save-images", "num-images", num_images), - "output_dir": output, + "output": output, "scale": scale, - "show_progress": ctx.quiet_mode, + "show_progress": not ctx.quiet_mode, + "threading": ctx.config.get_value("save-images", "threading"), "width": width, } ctx.add_command(cli_commands.save_images, save_images_args) - # Record that we added a save-images command to the pipeline so we can allow export-html + # Record that we added a save-images command to the pipeline so we can allow save-html # to run afterwards (it is dependent on the output). ctx.save_images = True +SAVE_EDL_HELP = """Save cuts in EDL format (CMX 3600).""" + + +@click.command("save-edl", cls=Command, help=SAVE_EDL_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-edl", "filename")), +) +@click.option( + "--title", + "-t", + metavar="NAME", + default=None, + type=click.STRING, + help="Title format to use.{}".format(USER_CONFIG.get_help_string("save-edl", "title")), +) +@click.option( + "--reel", + "-r", + metavar="REEL", + default=None, + type=click.STRING, + 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.{}".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: str | None, + title: str | None, + reel: str | None, + output: str | None, + start_timecode: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + save_edl_args = { + "filename": ctx.config.get_value("save-edl", "filename", filename), + "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) + + +SAVE_QP_HELP = """Save cuts as keyframes (I-frames) for video encoding. + +The resulting QP file can be used with the `--qpfile` argument in x264/x265. +""" + + +@click.command("save-qp", cls=Command, help=SAVE_QP_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + 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.{}".format( + USER_CONFIG.get_help_string("save-qp", "output", show_default=False) + ), +) +@click.option( + "--disable-shift", + "-d", + is_flag=True, + flag_value=True, + default=None, + 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: str | None, + output: str | None, + disable_shift: bool | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + save_qp_args = { + "filename": ctx.config.get_value("save-qp", "filename", filename), + "output": ctx.config.get_value("save-qp", "output", output), + "disable_shift": ctx.config.get_value("save-qp", "disable-shift", disable_shift), + } + ctx.add_command(cli_commands.save_qp, save_qp_args) + + +SAVE_FCP_HELP = """Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX).""" + + +@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.{}".format(USER_CONFIG.get_help_string("save-fcp", "filename")), +) +@click.option( + "--format", + metavar="TYPE", + type=click.Choice(CHOICE_MAP["save-fcp"]["format"], False), + default=None, + help="Format to export. TYPE must be one of: {}.{}".format( + ", ".join(CHOICE_MAP["save-fcp"]["format"]), + USER_CONFIG.get_help_string("save-fcp", "format"), + ), +) +@click.option( + "--output", + "-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.{}".format( + USER_CONFIG.get_help_string("save-fcp", "output", show_default=False) + ), +) +@click.pass_context +def save_fcp_command( + ctx: click.Context, + filename: str | None, + format: str | None, + output: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + 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_fcp, save_fcp_args) + + +SAVE_OTIO_HELP = """Save cuts as an OTIO timeline. + +Uses the Timeline.1 schema. OTIO (OpenTimelineIO) timelines can be imported by many video editors.""" + + +@click.command("save-otio", cls=Command, help=SAVE_OTIO_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-otio", "filename")), +) +@click.option( + "--name", + "-n", + metavar="NAME", + default=None, + type=click.STRING, + 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.{}".format( + USER_CONFIG.get_help_string("save-otio", "output", show_default=False) + ), +) +@click.option( + "--audio", + is_flag=True, + flag_value=True, + help="Include audio track (default).", +) +@click.option( + "--no-audio", + is_flag=True, + flag_value=True, + help="Exclude audio track.", +) +@click.pass_context +def save_otio_command( + ctx: click.Context, + filename: str | None, + name: str | None, + output: str | None, + audio: bool, + no_audio: bool, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + if audio and no_audio: + raise click.BadArgumentUsage("Only one of --audio or --no-audio can be specified.") + + save_otio_args = { + "filename": ctx.config.get_value("save-otio", "filename", filename), + "name": ctx.config.get_value("save-otio", "name", name), + "output": ctx.config.get_value("save-otio", "output", output), + "audio": ctx.config.get_value( + "save-otio", "audio", True if audio else False if no_audio else None + ), + } + ctx.add_command(cli_commands.save_otio, save_otio_args) + + # ---------------------------------------------------------------------- -# Commands Omitted From Help List +# CLI Sub-Command Registration # ---------------------------------------------------------------------- -# Info Commands +# Informational scenedetect.add_command(about_command) scenedetect.add_command(help_command) scenedetect.add_command(version_command) -# ---------------------------------------------------------------------- -# Commands Added To Help List -# ---------------------------------------------------------------------- - -# Input / Output -scenedetect.add_command(export_html_command) -scenedetect.add_command(list_scenes_command) +# Input scenedetect.add_command(load_scenes_command) -scenedetect.add_command(save_images_command) -scenedetect.add_command(split_video_command) scenedetect.add_command(time_command) -# Detection Algorithms +# Detectors scenedetect.add_command(detect_adaptive_command) scenedetect.add_command(detect_content_command) scenedetect.add_command(detect_hash_command) scenedetect.add_command(detect_hist_command) scenedetect.add_command(detect_threshold_command) + +# Output +scenedetect.add_command(list_scenes_command) +scenedetect.add_command(save_edl_command) +scenedetect.add_command(save_html_command) +scenedetect.add_command(save_images_command) +scenedetect.add_command(save_qp_command) +scenedetect.add_command(save_fcp_command) +scenedetect.add_command(save_otio_command) +scenedetect.add_command(split_video_command) + +# Deprecated Commands (Hidden From Help Output) +add_hidden_alias(save_html_command, "export-html") # Deprecated in v0.6.6, replaced with save-html diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 83a23bf2..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. # @@ -16,78 +16,119 @@ """ import logging -import typing as ty +import webbrowser from string import Template +from scenedetect._cli.config import FcpFormat from scenedetect._cli.context import CliContext +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, - write_scene_list, - write_scene_list_html, -) -from scenedetect.scene_manager import ( - save_images as save_images_impl, + expand_scenes_to_bounds, ) -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge logger = logging.getLogger("pyscenedetect") -def export_html( +def save_html( context: CliContext, scenes: SceneList, cuts: CutList, image_width: int, image_height: int, - html_name_format: str, + filename: str, + no_images: bool, + show: bool, ): - """Handles the `export-html` command.""" - (image_filenames, output_dir) = ( + """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 - else (None, context.output_dir) + else (None, context.output) ) - html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + + html_filename = Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name) if not html_filename.lower().endswith(".html"): html_filename += ".html" - html_path = get_and_create_path(html_filename, output_dir) + html_path = get_and_create_path(html_filename, output) write_scene_list_html( output_html_filename=html_path, scene_list=scenes, cut_list=cuts, - image_filenames=image_filenames, + image_filenames=None if no_images else image_filenames, image_width=image_width, image_height=image_height, ) + if show: + webbrowser.open(html_path) + + +def save_qp( + context: CliContext, + scenes: SceneList, + cuts: CutList, + output: str, + filename: str, + disable_shift: bool, +): + """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, + ) + 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, "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) + logger.info(f"QP file written to: {qp_path}") def list_scenes( context: CliContext, scenes: SceneList, cuts: CutList, - scene_list_output: bool, - scene_list_name_format: str, - output_dir: str, + no_output_file: bool, + filename: str, + output: str, skip_cuts: bool, quiet: bool, display_scenes: bool, display_cuts: bool, cut_format: str, + col_separator: str, + row_separator: str, ): """Handles the `list-scenes` command.""" + assert context.video_stream is not None # Write scene list CSV to if required. - if scene_list_output: - scene_list_filename = Template(scene_list_name_format).safe_substitute( + if not no_output_file: + scene_list_filename = Template(filename).safe_substitute( VIDEO_NAME=context.video_stream.name ) if not scene_list_filename.lower().endswith(".csv"): scene_list_filename += ".csv" scene_list_path = get_and_create_path( scene_list_filename, - output_dir, + output, ) logger.info("Writing scene list to CSV file:\n %s", scene_list_path) with open(scene_list_path, "w") as scene_list_file: @@ -96,6 +137,8 @@ def list_scenes( scene_list=scenes, include_cut_list=not skip_cuts, cut_list=cuts, + col_separator=col_separator, + row_separator=row_separator, ) # Suppress output if requested. if quiet: @@ -111,14 +154,7 @@ def list_scenes( -----------------------------------------------------------------------""", "\n".join( [ - " | %5d | %11d | %s | %11d | %s |" - % ( - i + 1, - start_time.get_frames() + 1, - start_time.get_timecode(), - end_time.get_frames(), - 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) ] ), @@ -139,16 +175,18 @@ def save_images( frame_margin: int, image_extension: str, encoder_param: int, - image_name_template: str, - output_dir: ty.Optional[str], + filename: str, + output: str | None, show_progress: bool, scale: int, height: int, width: int, interpolation: Interpolation, + threading: bool, ): """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, @@ -157,16 +195,17 @@ def save_images( frame_margin=frame_margin, image_extension=image_extension, encoder_param=encoder_param, - image_name_template=image_name_template, - output_dir=output_dir, + image_name_template=filename, + output_dir=output, show_progress=show_progress, scale=scale, height=height, width=width, interpolation=interpolation, + threading=threading, ) - # Save the result for use by `export-html` if required. - context.save_images_result = (images, output_dir) + # Save the result for use by `save-html` if required. + context.save_images_result = (images, output) def split_video( @@ -175,12 +214,28 @@ def split_video( cuts: CutList, name_format: str, use_mkvmerge: bool, - output_dir: str, + 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") # Add proper extension to filename template if required. dot_pos = name_format.rfind(".") @@ -195,7 +250,7 @@ def split_video( split_video_mkvmerge( input_video_path=context.video_stream.path, scene_list=scenes, - output_dir=output_dir, + output_dir=output, output_file_template=name_format, show_output=show_output, ) @@ -203,7 +258,7 @@ def split_video( split_video_ffmpeg( input_video_path=context.video_stream.path, scene_list=scenes, - output_dir=output_dir, + output_dir=output, output_file_template=name_format, arg_override=ffmpeg_args, show_progress=not context.quiet_mode, @@ -211,3 +266,102 @@ def split_video( ) if scenes: logger.info("Video splitting completed, scenes written to disk.") + + +def save_edl( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + output: str, + title: str, + reel: str, + start_timecode: str | None, +): + """Handles the `save-edl` command. Outputs in CMX 3600 format.""" + del cuts # We only use scene information. + assert context.video_stream is not None + video_name = context.video_stream.name + edl_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), + 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, + ) + + +def save_fcp( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + format: FcpFormat, + output: str, +): + """Handles the `save-fcp` command.""" + del cuts # We only use scene information. + if not scenes: + return + assert context.video_stream is not None + + video_stream = context.video_stream + video_name = str(video_stream.name) + 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}") + + +def save_otio( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + output: str, + name: str, + audio: bool, +): + """Handles the `save-otio` command.""" + del cuts # We only use scene information + assert context.video_stream is not None + video_stream = context.video_stream + video_name = str(video_stream.name) + otio_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), + output, + ) + 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 3ea5babe..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. # @@ -17,21 +17,26 @@ import logging import os import os.path +import typing as ty from abc import ABC, abstractmethod -from configparser import ConfigParser, ParsingError +from configparser import ConfigParser +from configparser import Error as ConfigParserError from enum import Enum -from typing import Any, AnyStr, Dict, List, Optional, Tuple, Union +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.frame_timecode import FrameTimecode -from scenedetect.scene_detector import FlashFilter +from scenedetect.output.video import _DEFAULT_FFMPEG_ARGS +from scenedetect.platform import DEBUG_MODE from scenedetect.scene_manager import Interpolation -from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +LogMessage = tuple[int, str] + class OptionParseFailure(Exception): """Raised when a value provided in a user config file fails validation.""" @@ -46,9 +51,9 @@ class ValidatedValue(ABC): @property @abstractmethod - def value(self) -> Any: + def value(self) -> ty.Any: """Get the value after validation.""" - raise NotImplementedError() + ... @staticmethod @abstractmethod @@ -58,7 +63,13 @@ def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue Raises: OptionParseFailure: Value from config file did not meet validation constraints. """ - raise NotImplementedError() + ... + + def __repr__(self) -> str: + return str(self.value) + + def __str__(self) -> str: + return str(self.value) class TimecodeValue(ValidatedValue): @@ -66,21 +77,15 @@ class TimecodeValue(ValidatedValue): Stores value in original representation.""" - def __init__(self, value: 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) -> Union[int, float, str]: + def value(self) -> int | float | str: return self._value - def __repr__(self) -> str: - return str(self.value) - - def __str__(self) -> str: - return str(self.value) - @staticmethod def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": try: @@ -96,9 +101,9 @@ class RangeValue(ValidatedValue): def __init__( self, - value: Union[int, float], - min_val: Union[int, float], - max_val: 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. @@ -108,24 +113,25 @@ def __init__( self._max_val = max_val @property - def value(self) -> Union[int, float]: + def value(self) -> int | float: return self._value @property - def min_val(self) -> Union[int, float]: + def min_val(self) -> int | float: """Minimum value of the range.""" return self._min_val @property - def max_val(self) -> Union[int, float]: + def max_val(self) -> int | float: """Maximum value of the range.""" return self._max_val - def __repr__(self) -> str: - return str(self.value) - - def __str__(self) -> str: - return str(self.value) + @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": @@ -137,17 +143,64 @@ 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 = (",", "/", "(", ")") + """Characters to ignore.""" + + 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: tuple[int, ...] = () + if isinstance(value, str): + translation_table = str.maketrans( + {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} + ) + values = value.translate(translation_table).split() + crop = tuple(int(val) for val in values) + elif isinstance(value, tuple): + crop = value + if not len(crop) == 4: + raise ValueError("Crop region must be four numbers of the form X0 Y0 X1 Y1!") + if any(coordinate < 0 for coordinate in crop): + raise ValueError("Crop coordinates must be >= 0") + (x0, y0, x1, y1) = crop + self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + + @property + def value(self) -> tuple[int, int, int, int] | None: + return self._crop + + def __str__(self) -> str: + 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": + try: + return CropValue(config_value) + except ValueError as ex: + raise OptionParseFailure(f"{ex}") from ex + + 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: Union[str, ContentDetector.Components]): + def __init__(self, value: str | ContentDetector.Components): if isinstance(value, ContentDetector.Components): self._value = value else: @@ -160,14 +213,11 @@ def __init__(self, value: Union[str, ContentDetector.Components]): self._value = ContentDetector.Components(*(float(val) for val in values)) @property - def value(self) -> Tuple[float, float, float, float]: + def value(self) -> ContentDetector.Components: return self._value - def __repr__(self) -> str: - return str(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": @@ -184,28 +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 __repr__(self) -> str: - return str(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": @@ -217,6 +266,47 @@ def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeVal ) from ex +class EscapedString(ValidatedValue): + """Strings that can contain escape sequences, e.g. the literal \n.""" + + def __init__(self, value: str, length_limit: int = 0): + self._value = value.encode("utf-8").decode("unicode_escape") + if length_limit and len(self._value) > length_limit: + raise OptionParseFailure(f"Value must be no longer than {length_limit} characters.") + + @property + def value(self) -> str: + """Get the value after validation.""" + return self._value + + @staticmethod + def from_config( + config_value: str, default: "EscapedString", length_limit: int = 0 + ) -> "EscapedString": + try: + return EscapedString(config_value, length_limit) + except (UnicodeDecodeError, UnicodeEncodeError) as ex: + raise OptionParseFailure( + "Value must be valid UTF-8 string with escape characters." + ) from ex + + +class EscapedChar(EscapedString): + """Strings that can contain escape sequences but can be a maximum of 1 character in length.""" + + def __init__(self, value: str): + super().__init__(value, length_limit=1) + + @staticmethod + def from_config(config_value: str, default: "EscapedString") -> "EscapedChar": + 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): """Format to display timecodes.""" @@ -229,26 +319,38 @@ class TimecodeFormat(Enum): def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.FRAMES: - return str(timecode.get_frames()) + return str(timecode.frame_num) if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return "%.3f" % timecode.get_seconds() + return f"{timecode.seconds:.3f}" raise RuntimeError("Unhandled format specifier.") -ConfigValue = Union[bool, int, float, str] -ConfigDict = Dict[str, Dict[str, ConfigValue]] +class FcpFormat(Enum): + """Format to use with the `save-fcp` command.""" + + FCPX = 0 + """Final Cut Pro X XML Format""" + FCP7 = 1 + """Final Cut Pro 7 XML Format""" + + +# `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: AnyStr = "scenedetect.cfg" -_CONFIG_FILE_DIR: 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: 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 -# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv CONFIG_MAP: ConfigDict = { "backend-opencv": { "max-decode-attempts": 5, @@ -262,13 +364,12 @@ def format(self, timecode: FrameTimecode) -> str: "kernel-size": KernelSizeValue(-1), "luma-only": False, "min-content-val": RangeValue(15.0, min_val=0.0, max_val=255.0), - "min-delta-hsv": RangeValue(15.0, min_val=0.0, max_val=255.0), "min-scene-len": TimecodeValue(0), "threshold": RangeValue(3.0, min_val=0.0, max_val=255.0), "weights": ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), }, "detect-content": { - "filter-mode": "merge", + "filter-mode": FlashFilter.Mode.MERGE, "kernel-size": KernelSizeValue(-1), "luma-only": False, "min-scene-len": TimecodeValue(0), @@ -278,13 +379,13 @@ def format(self, timecode: FrameTimecode) -> str: "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, @@ -295,27 +396,24 @@ def format(self, timecode: FrameTimecode) -> str: "load-scenes": { "start-col-name": "Start Frame", }, - "export-html": { - "filename": "$VIDEO_NAME-Scenes.html", - "image-height": 0, - "image-width": 0, - "no-images": False, - }, "list-scenes": { - "cut-format": "timecode", + "cut-format": TimecodeFormat.TIMECODE, + "col-separator": EscapedChar(","), "display-cuts": True, "display-scenes": True, "filename": "$VIDEO_NAME-Scenes.csv", "output": None, + "row-separator": EscapedString("\n"), "no-output-file": False, "quiet": False, "skip-cuts": False, }, "global": { "backend": "opencv", + "crop": CropValue(), "default-detector": "detect-adaptive", "downscale": 0, - "downscale-method": "linear", + "downscale-method": Interpolation.LINEAR, "drop-short-scenes": False, "frame-skip": 0, "merge-last-scene": False, @@ -323,22 +421,54 @@ def format(self, timecode: FrameTimecode) -> str: "output": None, "verbosity": "info", }, + "save-edl": { + "filename": "$VIDEO_NAME.edl", + "output": None, + "reel": "AX", + "start-timecode": None, + "title": "$VIDEO_NAME", + }, + "save-html": { + "filename": "$VIDEO_NAME-Scenes.html", + "image-height": 0, + "image-width": 0, + "no-images": False, + "show": False, + }, "save-images": { "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, "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), "scale": 1.0, - "scale-method": "linear", + "scale-method": Interpolation.LINEAR, + "threading": True, "width": 0, }, + "save-otio": { + "audio": True, + "filename": "$VIDEO_NAME.otio", + "name": "$VIDEO_NAME (PySceneDetect)", + "output": None, + }, + "save-qp": { + "disable-shift": False, + "filename": "$VIDEO_NAME.qp", + "output": None, + }, + "save-fcp": { + "format": FcpFormat.FCPX, + "filename": "$VIDEO_NAME.xml", + "output": None, + }, "split-video": { - "args": DEFAULT_FFMPEG_ARGS, + "args": _DEFAULT_FFMPEG_ARGS, "copy": False, + "expand": False, "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, "mkvmerge": False, @@ -352,7 +482,7 @@ def format(self, timecode: FrameTimecode) -> str: 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: Dict[str, Dict[str, List[str]]] = { +CHOICE_MAP: dict[str, dict[str, list[str]]] = { "backend-pyav": { "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], }, @@ -378,6 +508,9 @@ def format(self, timecode: FrameTimecode) -> str: "format": ["jpeg", "png", "webp"], "scale-method": [value.name.lower() for value in Interpolation], }, + "save-fcp": { + "format": [value.name.lower() for value in FcpFormat], + }, "split-video": { "preset": [ "ultrafast", @@ -396,110 +529,184 @@ def format(self, timecode: FrameTimecode) -> str: of a set to preserve order when generating error contexts. Values are case-insensitive, and must be in lowercase in this map.""" -# TODO: This isn't ideal for enums since this could be derived from the type directly, but it works. - - -def _validate_structure(config: ConfigParser) -> List[str]: - """Validates the layout of the section/option mapping. - - Returns: - List of any parsing errors in human-readable form. - """ - errors: List[str] = [] - for section in config.sections(): - if section not in CONFIG_MAP.keys(): - errors.append("Unsupported config section: [%s]" % (section)) +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) -> 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: list[LogMessage] = [] + success = True + all_sections = set(parser.sections()) + for section in all_sections: + section_name = section + if section in DEPRECATED_COMMANDS: + section = DEPRECATED_COMMANDS[section] + logs.append( + ( + logging.WARNING, + f"WARNING: [{section_name}] is deprecated and will be removed!" + f"Use [{section}] instead.", + ) + ) + # The parser already handled duplicate sections, but it doesn't know about deprecated + # aliases. If there's a conflict, make sure we error out instead of warning. + if section in all_sections: + success = False + logs.append( + ( + logging.ERROR, + f"[{section_name}] conflicts with [{section}], only specify one.", + ) + ) + continue + elif section not in CONFIG_MAP: + success = False + logs.append((logging.ERROR, f"Unsupported config section: [{section_name}]")) continue - for option_name, _ in config.items(section): - if option_name not in CONFIG_MAP[section].keys(): - errors.append("Unsupported config option in [%s]: %s" % (section, option_name)) - return errors - - -def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: - """Process the given configuration into a key-value mapping. - - Returns: - Configuration mapping and list of any processing errors in human readable form. - """ - out_map: ConfigDict = {} - errors: List[str] = [] + for option_name, _ in parser.items(section_name): + if option_name not in CONFIG_MAP[section]: + success = False + logs.append( + ( + logging.ERROR, + f"Unsupported config option in [{section_name}]: [{option_name}]", + ) + ) + return (success, logs) + + +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) + if not success: + return (None, logs) + config: ConfigDict = {} + success = True + # Re-map deprecated config sections to their replacements. Structure validation above should + # ensure no conflicts between the two. + for deprecated_command in DEPRECATED_COMMANDS: + if deprecated_command in parser: + replacement = DEPRECATED_COMMANDS[deprecated_command] + parser[replacement] = parser[deprecated_command] + del parser[deprecated_command] for command in CONFIG_MAP: - out_map[command] = {} + config[command] = {} for option in CONFIG_MAP[command]: - if command in config and option in config[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" - out_map[command][option] = config.getboolean(command, option) + config[command][option] = parser.getboolean(command, option) continue - elif isinstance(CONFIG_MAP[command][option], int): + elif isinstance(default_value, int): value_type = "integer" - out_map[command][option] = config.getint(command, option) + config[command][option] = parser.getint(command, option) continue - elif isinstance(CONFIG_MAP[command][option], float): + elif isinstance(default_value, float): value_type = "number" - out_map[command][option] = config.getfloat(command, option) + config[command][option] = parser.getfloat(command, option) continue + elif isinstance(default_value, Enum): + config_value = ( + parser.get(command, option).replace("\n", " ").strip().upper() + ) + try: + parsed = default_value.__class__[config_value] + config[command][option] = parsed + except TypeError: + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [{}] option {}': {}. Must be one of: {}.".format( + command, + option, + parser.get(command, option), + ", ".join( + str(choice) for choice in CHOICE_MAP[command][option] + ), + ), + ) + ) + continue + except ValueError as _: - errors.append( - "Invalid [%s] value for %s: %s is not a valid %s." - % (command, option, config.get(command, option), value_type) + success = False + logs.append( + ( + logging.ERROR, + f"Invalid value for [{command}] option '{option}': {parser.get(command, option)} is not a valid {value_type}.", + ) ) continue # Handle custom validation types. - config_value = config.get(command, option) - default = CONFIG_MAP[command][option] - option_type = type(default) - if issubclass(option_type, ValidatedValue): + config_value = parser.get(command, option) + if isinstance(default_value, ValidatedValue): + option_type = type(default_value) try: - out_map[command][option] = option_type.from_config( - config_value=config_value, default=default + config[command][option] = option_type.from_config( + config_value=config_value, default=default_value ) except OptionParseFailure as ex: - errors.append( - "Invalid [%s] value for %s:\n %s\n%s" - % (command, option, config_value, ex.error) + success = False + logs.append( + ( + logging.ERROR, + f"Invalid value for [{command}] option '{option}': {config_value}\nError: {ex.error}", + ) ) continue # If we didn't process the value as a given type, handle it as a string. We also # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: - config_value = config.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]: - errors.append( - "Invalid [%s] value for %s: %s. Must be one of: %s." - % ( + config_value = parser.get(command, option).replace("\n", " ").strip() + 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, - config.get(command, option), + parser.get(command, option), ", ".join(choice for choice in CHOICE_MAP[command][option]), - ) + ), ) - continue - out_map[command][option] = config_value + ) + continue + config[command][option] = config_value continue - return (out_map, errors) + if not success: + return (None, logs) + return (config, logs) class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" - def __init__(self, init_log: Tuple[int, str], reason: 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: 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: List[Tuple[int, str]] = [] + self._init_log: list[tuple[int, str]] = [] self._initialized = False try: @@ -514,7 +721,7 @@ def __init__(self, path: 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 @@ -534,42 +741,41 @@ 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: with open(path) as config_file: config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) - except ParsingError as ex: - raise ConfigLoadFailure(self._init_log, reason=ex) from None - except OSError as ex: + except (ConfigParserError, OSError) as ex: + 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). - errors = _validate_structure(config) - if not errors: - self._config, errors = _parse_config(config) - if errors: - for log_str in errors: - self._init_log.append((logging.ERROR, log_str)) + (config, logs) = _parse_config(config) + for verbosity, message in logs: + self._log(verbosity, message) + if config is None: raise ConfigLoadFailure(self._init_log) + self._config = config def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" @@ -579,23 +785,29 @@ def get_value( self, command: str, option: str, - override: 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: - return override - if command in self._config and option in self._config[command]: + value = override + elif command in self._config and option in self._config[command]: value = self._config[command][option] else: - value = CONFIG_MAP[command][option] - if issubclass(type(value), ValidatedValue): + value = default_value + if isinstance(value, ValidatedValue): return value.value + 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: 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. @@ -612,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 1062cc71..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. # @@ -22,7 +22,10 @@ CHOICE_MAP, ConfigLoadFailure, ConfigRegistry, + CropValue, ) +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from scenedetect.detector import SceneDetector from scenedetect.detectors import ( AdaptiveDetector, ContentDetector, @@ -30,12 +33,10 @@ HistogramDetector, ThresholdDetector, ) -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -from scenedetect.platform import init_logger -from scenedetect.scene_detector import FlashFilter, SceneDetector -from scenedetect.scene_manager import Interpolation, SceneManager +from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available +from scenedetect.platform import DEBUG_MODE, init_logger +from scenedetect.scene_manager import SceneManager from scenedetect.stats_manager import StatsManager -from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = logging.getLogger("pyscenedetect") @@ -80,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 export-html + 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: FrameTimecode = None # time -s/--start - self.end_time: FrameTimecode = None # time -e/--end - self.duration: 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_dir: 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_dir" in command_args and command_args["output_dir"] is None: - command_args["output_dir"] = self.output_dir + 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. @@ -141,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" @@ -153,21 +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], - downscale: ty.Optional[int], - frame_skip: int, - min_scene_len: str, - drop_short_scenes: bool, - merge_last_scene: bool, - backend: ty.Optional[str], + 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 @@ -183,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() @@ -204,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__) @@ -212,10 +238,10 @@ def handle_options( logger.log(log_level, log_str) if init_failure: logger.critical("Error processing configuration file.") - raise click.Abort() + raise SystemExit(1) if self.config.config_dict: - logger.debug("Current configuration:\n%s", str(self.config.config_dict)) + logger.debug("Current configuration:\n%s", str(self.config.config_dict).encode("utf-8")) logger.debug("Parsing program options.") if stats is not None and frame_skip: @@ -234,22 +260,22 @@ 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_dir = self.config.get_value("global", "output", output) - if self.output_dir: - logger.debug("Output directory set:\n %s", self.output_dir) + self.output = self.config.get_value("global", "output", output) + if self.output: + logger.debug("Output directory set:\n %s", self.output) self.min_scene_len = self.parse_timecode( min_scene_len if min_scene_len is not None else self.config.get_value("global", "min-scene-len"), ) - self.drop_short_scenes = drop_short_scenes or self.config.get_value( - "global", "drop-short-scenes" + self.drop_short_scenes = self.config.get_value( + "global", "drop-short-scenes", drop_short_scenes ) - self.merge_last_scene = merge_last_scene or self.config.get_value( - "global", "merge-last-scene" + self.merge_last_scene = self.config.get_value( + "global", "merge-last-scene", merge_last_scene ) self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) @@ -280,15 +306,29 @@ 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 None - scene_manager.interpolation = Interpolation[ - self.config.get_value("global", "downscale-method").upper() - ] + raise click.BadParameter(str(ex), param_hint="downscale factor") from ex + scene_manager.interpolation = self.config.get_value("global", "downscale-method") + + # If crop was set, make sure it's valid (e.g. it should cover at least a single pixel). + try: + 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) + raise ValueError(f"{region} is outside of video boundary of {frame_size}") + scene_manager.crop = crop + except ValueError as ex: + logger.debug(str(ex)) + raise click.BadParameter(str(ex), param_hint="--crop") from ex + self.scene_manager = scene_manager # @@ -297,28 +337,22 @@ 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_MODE: + raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None @@ -326,56 +360,31 @@ 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": FlashFilter.Mode[ - self.config.get_value("detect-content", "filter-mode", filter_mode).upper() - ], + "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, - min_delta_hsv: ty.Optional[float] = 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.""" - # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. - if min_delta_hsv is not None: - logger.error("-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.") - if min_content_val is None: - min_content_val = min_delta_hsv - # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. - if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error( - "[detect-adaptive] config file option `min-delta-hsv` is deprecated" - ", use `min-delta-hsv` instead." - ) - if self.config.is_default("detect-adaptive", "min-content-val"): - self.config.config_dict["detect-adaptive"]["min-content-val"] = ( - self.config.config_dict["detect-adaptive"]["min-deleta-hsv"] - ) - - 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_MODE: + raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None return { @@ -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,23 +530,38 @@ def _open_video_stream( else: self.video_stream = open_video( path=input_path, - framerate=framerate, + frame_rate=frame_rate, backend=backend, ) - logger.debug("Video opened using backend %s", type(self.video_stream).__name__) + 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} + Frame rate: {float(rate):.3f} ({rate.numerator}/{rate.denominator}) + Duration: {duration_str}""") + except FrameRateUnavailable as ex: + 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_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_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 313997fd..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,10 +15,11 @@ import logging import os import time -import typing as ty +import warnings from scenedetect._cli.context import CliContext -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.backends import VideoStreamCv2, VideoStreamMoviePy +from scenedetect.common import FrameTimecode from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts from scenedetect.video_stream import SeekError @@ -39,6 +40,12 @@ def run_scenedetect(context: CliContext): logger.debug("No input specified.") return + # Suppress warnings when reading past EOF in MoviePy (#461). + if VideoStreamMoviePy and isinstance(context.video_stream, VideoStreamMoviePy): + is_debug = context.config.get_value("global", "verbosity") != "debug" + if not is_debug: + warnings.filterwarnings("ignore", module="moviepy") + if context.load_scenes_input: # Skip detection if load-scenes was used. logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) @@ -49,7 +56,10 @@ def run_scenedetect(context: CliContext): logger.info("Loaded %d scenes.", len(scenes)) else: # Perform scene detection on input. - scenes, cuts = _detect(context) + result = _detect(context) + if result is None: + return + scenes, cuts = result scenes = _postprocess_scene_list(context, scenes) # Handle -s/--stats option. _save_stats(context) @@ -57,7 +67,7 @@ def run_scenedetect(context: CliContext): logger.info( "Detected %d scenes, average shot length %.1f seconds.", len(scenes), - sum([(end_time - start_time).get_seconds() for start_time, end_time in scenes]) + sum([(end_time - start_time).seconds for start_time, end_time in scenes]) / float(len(scenes)), ) else: @@ -71,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: @@ -95,7 +117,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: logger.critical( "Failed to seek to %s / frame %d: %s", context.start_time.get_timecode(), - context.start_time.get_frames(), + context.start_time.frame_num, str(ex), ) return None @@ -109,8 +131,8 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: ) # Handle case where video failure is most likely due to multiple audio tracks (#179). - # TODO(#380): Ensure this does not erroneusly fire. - if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv": + # TODO(https://scenedetect.com/issues/380): Ensure this does not erroneusly fire. + if num_frames <= 0 and isinstance(context.video_stream, VideoStreamCv2): logger.critical( "Failed to read any frames from video file. This could be caused by the video" " having multiple audio tracks. If so, try installing the PyAV backend:\n" @@ -142,8 +164,9 @@ 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_dir) + path = get_and_create_path(context.stats_file_path, context.output) logger.info("Saving frame metrics to stats file: %s", path) with open(path, mode="w") as file: context.stats_manager.save_to_csv(csv_file=file) @@ -151,28 +174,33 @@ 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) if context.load_scenes_column_name not in csv_headers: csv_headers = next(file_reader) - # Check to make sure column headers are present + # Check to make sure column headers are present and then load the data. if context.load_scenes_column_name not in csv_headers: raise ValueError("specified column header for scene start is not present") - col_idx = csv_headers.index(context.load_scenes_column_name) - cut_list = sorted( - FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 - for row in file_reader - ) - # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame - # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` - # but this part of the API is being reworked and hasn't been used by any detectors yet. + 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=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 + # cut point where the next scenes starts. if cut_list: cut_list = cut_list[1:] @@ -181,15 +209,15 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: start_time = context.start_time cut_list = [cut for cut in cut_list if cut > context.start_time] - end_time = context.video_stream.duration - if context.end_time is not None or context.duration is not None: - if context.end_time is not None: - end_time = context.end_time - elif context.duration is not None: - end_time = start_time + context.duration - end_time = min(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, video_duration) + elif context.duration is not None: + 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) - return get_scenes_from_cuts( - cut_list=cut_list, start_pos=start_time, end_pos=end_time - ), cut_list + return (scene_list, cut_list) 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 df01519d..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.""" @@ -287,7 +285,7 @@ def __str__(self): # Set encoding page.append( - '' % self.encoding + '' % self.encoding ) for table in self.tables: @@ -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 a8bd763a..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. # @@ -32,10 +32,10 @@ .. code:: python from scenedetect import open_video - video = open_video('video.mp4') + video = open_video("video.mp4") An optional backend from :data:`AVAILABLE_BACKENDS` can be passed to :func:`open_video` -(e.g. `backend='opencv'`). Additional keyword arguments passed to :func:`open_video` +(e.g. `backend="opencv"`). Additional keyword arguments passed to :func:`open_video` will be forwarded to the backend constructor. If the specified backend is unavailable, or loading the video fails, ``opencv`` will be tried as a fallback. @@ -45,11 +45,19 @@ # Manually importing and constructing a backend: from scenedetect.backends.opencv import VideoStreamCv2 - video = VideoStreamCv2('video.mp4') + video = VideoStreamCv2("video.mp4") 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/ -from typing import Dict, Type - # 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: Dict[str, 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: 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 e85f37c4..aef9844d 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,62 +16,117 @@ image sequences or AviSynth scripts are supported as inputs. """ +import os +import time +import typing as ty +from fractions import Fraction from logging import getLogger -from typing import AnyStr, Optional, Tuple, Union import cv2 import numpy as np from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.frame_timecode import 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: AnyStr, framerate: Optional[float] = None, print_infos: bool = False): + def __init__( + 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(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is + # used, once internal callers and downstream users have had a release to migrate. + if frame_rate is None: + frame_rate = framerate # 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." - ) - - self._path = 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: Union[bool, np.ndarray] = False - self._last_frame_rgb: 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 @@ -81,12 +136,15 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: """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) -> Union[bytes, str]: + def path(self) -> str: """Video path.""" return self._path @@ -101,12 +159,12 @@ def is_seekable(self) -> bool: return True @property - def frame_size(self) -> 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) -> 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"] @@ -114,7 +172,7 @@ def duration(self) -> Optional[FrameTimecode]: @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - # TODO: Use cached_property once Python 3.7 support is deprecated. + # TODO: Use cached_property. if self._aspect_ratio is None: # MoviePy doesn't support extracting the aspect ratio yet, so for now we just fall # back to using OpenCV to determine it. @@ -129,13 +187,18 @@ def aspect_ratio(self) -> float: def position(self) -> FrameTimecode: """Current position within stream as FrameTimecode. - This can be interpreted as presentation time stamp of the last frame which was - decoded by calling `read` with advance=True. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" + This can be interpreted as presentation time stamp of the last frame which was decoded by + 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: @@ -143,19 +206,17 @@ def position_ms(self) -> float: The first frame has a time of 0.0 ms. This method will always return 0.0 if no frames have been `read`.""" - return self.position.get_seconds() * 1000.0 + return self.position.seconds * 1000.0 @property def frame_number(self) -> int: """Current position within stream in frames as an int. - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" + 0 indicates that no frames have been `read`, 1 indicates the first frame was just read. + """ return self._frame_number - def seek(self, target: 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). @@ -174,57 +235,61 @@ def seek(self, target: Union[FrameTimecode, float, int]): SeekError: An error occurs while seeking, or seeking is not supported. ValueError: `target` is not a valid value (i.e. it is negative). """ + success = False if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) + duration = self.duration + assert duration is not None try: - self._reader.get_frame(target.get_seconds()) + 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, + FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1, + ) + success = True except OSError as ex: - # Leave the object in a valid state. - self.reset() - # TODO(#380): Other backends do not currently throw an exception if attempting to seek - # past EOF. We need to ensure consistency for seeking past end of video with respect to - # errors and behaviour, and should probably gracefully stop at the last frame instead - # of throwing an exception. - if target >= self.duration: + # TODO(https://scenedetect.com/issues/380): Other backends do not currently throw an + # exception if attempting to seek past EOF. + # + # We need to ensure consistency for seeking past end of video with respect to errors and + # behaviour, and should probably gracefully stop at the last frame instead of throwing. + if target >= duration: raise SeekError("Target frame is beyond end of video!") from ex raise - self._last_frame = self._reader.lastread - self._frame_number = target.frame_num + finally: + # Leave the object in a valid state on any errors. + if not success: + self.reset() - def reset(self): + def reset(self, print_infos=False): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - self._reader.initialize() - self._last_frame = self._reader.read_frame() + self._last_frame = False + self._last_frame_rgb = None self._frame_number = 0 self._eof = False + self._reader = _retry_on_oserror( + "reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) + ) - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ - if not advance: - if self._last_frame_rgb is None: - self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) - return self._last_frame_rgb - if not hasattr(self._reader, "lastread"): + def read(self, decode: bool = True) -> np.ndarray | bool: + if not hasattr(self._reader, "lastread") or self._eof: return False - self._last_frame = self._reader.lastread - self._reader.read_frame() - if self._last_frame is self._reader.lastread: - # Didn't decode a new frame, must have hit EOF. + has_last_read = hasattr(self._reader, "last_read") + # In MoviePy 2.0 there is a separate property we need to read named differently (#461). + self._last_frame = self._reader.last_read if has_last_read else self._reader.lastread + # Read the *next* frame for the following call to read, and to check for EOF. + frame = self._reader.read_frame() + if frame is self._last_frame: if self._eof: return False self._eof = True self._frame_number += 1 - if decode: - if self._last_frame is not None: - self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) + 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 True + return not self._eof diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 862a19e2..12294664 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,16 +18,30 @@ """ import math +import os import os.path +import warnings +from fractions import Fraction from logging import getLogger -from typing import AnyStr, Optional, Tuple, Union import cv2 import numpy as np -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -from scenedetect.platform import get_file_name -from scenedetect.video_stream import FrameRateUnavailable, SeekError, VideoOpenFailure, VideoStream +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, + VideoOpenFailure, + VideoStream, +) logger = getLogger("pyscenedetect") @@ -58,17 +72,19 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: AnyStr = None, - framerate: Optional[float] = None, + path: StrPath | None = None, + frame_rate: FrameRate | None = None, max_decode_attempts: int = 5, - path_or_device: 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 @@ -76,40 +92,49 @@ 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__() - # TODO(v0.7): Replace with DeprecationWarning that `path_or_device` will be removed in v0.8. + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is + # used, once internal callers and downstream users have had a release to migrate. + if frame_rate is None: + frame_rate = framerate if path_or_device is not None: - logger.error("path_or_device is deprecated, use path or VideoCaptureAdapter instead.") - path = path_or_device - if path is None: + warnings.warn( + "The `path_or_device` argument is deprecated, use `path` or `VideoCaptureAdapter`" + " instead.", + DeprecationWarning, + stacklevel=2, + ) + 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: Optional[cv2.VideoCapture] = ( - None # Reference to underlying cv2.VideoCapture object. - ) - self._frame_rate: 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 @@ -123,7 +148,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 # @@ -134,23 +158,19 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: - """Framerate in frames/sec.""" - assert self._frame_rate + def frame_rate(self) -> Fraction: return self._frame_rate @property - def path(self) -> Union[bytes, str]: - """Video or device path.""" + 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 def name(self) -> str: - """Name of the video, without extension, or device.""" if self._is_device: return self.path file_name: str = get_file_name(self.path, include_extension=False) @@ -168,7 +188,7 @@ def is_seekable(self) -> bool: return not self._is_device @property - def frame_size(self) -> 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)), @@ -176,7 +196,7 @@ def frame_size(self) -> Tuple[int, int]: ) @property - def duration(self) -> 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 @@ -188,116 +208,97 @@ def aspect_ratio(self) -> float: return _get_aspect_ratio(self._cap) @property - def position(self) -> FrameTimecode: - """Current position within stream as FrameTimecode. - - This can be interpreted as presentation time stamp of the last frame which was - decoded by calling `read` with advance=True. + def timecode(self) -> Timecode: + """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, 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) - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" - if self.frame_number < 1: - return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + @property + def position(self) -> FrameTimecode: + 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: - """Current position within stream as a float of the presentation time in milliseconds. - The first frame has a time of 0.0 ms. - - This method will always return 0.0 if no frames have been `read`.""" return self._cap.get(cv2.CAP_PROP_POS_MSEC) @property def frame_number(self) -> int: - """Current position within stream in frames as an int. - - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) - def seek(self, target: Union[FrameTimecode, float, int]): - """Seek to the given timecode. If given as a frame number, represents the current seek - pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). - - For 1-based indices (first frame is frame #1), the target frame number needs to be converted - to 0-based by subtracting one. For example, if we want to seek to the first frame, we call - seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed - by read(), at which point frame_number will be 5. - - Not supported if the VideoStream is a device/camera. Untested with web streams. - - Arguments: - target: Target position in video stream to seek to. - If float, interpreted as time in seconds. - If int, interpreted as frame number. - Raises: - SeekError: An error occurs while seeking, or seeking is not supported. - ValueError: `target` is not a valid value (i.e. it is negative). - """ + 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!") - - # 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).get_frames() - 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) - - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends, - or the maximum number of decode attempts has passed. + self._open_capture(float(self._frame_rate)) - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ + def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False - # Grab the next frame if possible. - if advance: - 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): - for _ in range(self._max_decode_attempts): - has_grabbed = self._cap.grab() - if has_grabbed: - break - # Report previous failure in debug mode. - if has_grabbed: - self._decode_failures += 1 - logger.debug("Frame failed to decode.") - if not self._warning_displayed and self._decode_failures > 1: - logger.warning("Failed to decode some frames, results may be inaccurate.") - # We didn't manage to grab a frame even after retrying, so just return. - if not has_grabbed: - return False - self._has_grabbed = True + has_grabbed = self._cap.grab() + # If we failed to grab the frame, retry a few times if required. + if not has_grabbed: + 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: + break + # Report previous failure in debug mode. + if has_grabbed: + self._decode_failures += 1 + logger.debug("Frame failed to decode.") + if not self._warning_displayed and self._decode_failures > 1: + logger.warning("Failed to decode some frames, results may be inaccurate.") + # We didn't manage to grab a frame even after retrying, so just return. + if not has_grabbed: + return False + self._has_grabbed = True # Need to make sure we actually grabbed a frame before calling retrieve. if decode and self._has_grabbed: _, frame = self._cap.retrieve() @@ -308,17 +309,23 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b # Private Methods # - def _open_capture(self, framerate: 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(): @@ -341,20 +348,20 @@ def _open_capture(self, framerate: 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(#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. @@ -363,8 +370,9 @@ class VideoCaptureAdapter(VideoStream): def __init__( self, cap: cv2.VideoCapture, - framerate: 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. @@ -372,31 +380,38 @@ 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) + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is + # used, once internal callers and downstream users have had a release to migrate. + if 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 @@ -415,7 +430,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 # @@ -426,9 +440,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 @@ -447,7 +460,7 @@ def is_seekable(self) -> bool: return False @property - def frame_size(self) -> 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)), @@ -455,7 +468,7 @@ def frame_size(self) -> Tuple[int, int]: ) @property - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: @@ -469,37 +482,26 @@ def aspect_ratio(self) -> float: @property def position(self) -> FrameTimecode: - """Current position within stream as FrameTimecode. Use the :meth:`position_ms` - if an accurate duration of elapsed time is required, as `position` is currently - based off of the number of frames, and may not be accurate for devicesor live streams. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" 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: - """Current position within stream as a float of the presentation time in milliseconds. - The first frame has a time of 0.0 ms. - - This method will always return 0.0 if no frames have been `read`.""" if self._num_frames == 0: return 0.0 return self._cap.get(cv2.CAP_PROP_POS_MSEC) - self._time_base @property def frame_number(self) -> int: - """Current position within stream in frames as an int. - - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" return self._num_frames - def seek(self, target: Union[FrameTimecode, float, int]): + def seek(self, target: TimecodeLike): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") @@ -507,41 +509,28 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends, - or the maximum number of decode attempts has passed. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ + def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False - # Grab the next frame if possible. - if advance: - has_grabbed = self._cap.grab() - # If we failed to grab the frame, retry a few times if required. - if not has_grabbed: - for _ in range(self._max_read_attempts): - has_grabbed = self._cap.grab() - if has_grabbed: - break - # Report previous failure in debug mode. + has_grabbed = self._cap.grab() + # If we failed to grab the frame, retry a few times if required. + if not has_grabbed: + for _ in range(self._max_read_attempts): + has_grabbed = self._cap.grab() if has_grabbed: - self._decode_failures += 1 - logger.debug("Frame failed to decode.") - if not self._warning_displayed and self._decode_failures > 1: - logger.warning("Failed to decode some frames, results may be inaccurate.") - # We didn't manage to grab a frame even after retrying, so just return. - if not has_grabbed: - return False - if self._num_frames == 0: - self._time_base = self._cap.get(cv2.CAP_PROP_POS_MSEC) - self._num_frames += 1 + break + # Report previous failure in debug mode. + if has_grabbed: + self._decode_failures += 1 + logger.debug("Frame failed to decode.") + if not self._warning_displayed and self._decode_failures > 1: + logger.warning("Failed to decode some frames, results may be inaccurate.") + # We didn't manage to grab a frame even after retrying, so just return. + if not has_grabbed: + return False + if self._num_frames == 0: + self._time_base = self._cap.get(cv2.CAP_PROP_POS_MSEC) + self._num_frames += 1 # Need to make sure we actually grabbed a frame before calling retrieve. if decode and self._num_frames > 0: _, frame = self._cap.retrieve() diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index cba203c7..0933c547 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -5,30 +5,37 @@ # [ 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 +from fractions import Fraction from logging import getLogger -from typing import AnyStr, BinaryIO, Optional, Tuple, Union import av import numpy as np -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -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"] -VALID_THREAD_MODES = [ - av.codec.context.ThreadType.NONE, - av.codec.context.ThreadType.SLICE, - av.codec.context.ThreadType.FRAME, - av.codec.context.ThreadType.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): @@ -40,11 +47,12 @@ class VideoStreamAv(VideoStream): # calculates the end time. def __init__( self, - path_or_io: Union[AnyStr, BinaryIO], - framerate: Optional[float] = None, - name: Optional[str] = None, - threading_mode: 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. @@ -56,7 +64,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 @@ -67,45 +76,58 @@ 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(#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) + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is + # used, once internal callers and downstream users have had a release to migrate. + if 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 = 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: - threading_mode = threading_mode.upper() - if threading_mode not in VALID_THREAD_MODES: - raise ValueError("Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES) + try: + threading_mode = av.codec.context.ThreadType[threading_mode.upper()] # type: ignore[attr-defined] + except KeyError as _: + raise ValueError( + f"Invalid threading mode! Must be one of: {VALID_THREAD_MODES}" + ) 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 @@ -115,31 +137,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() - # TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations. - # See https://pyav.org/docs/develop/api/stream.html for details. - frame_rate = frame_rate.numerator / float(frame_rate.denominator) - if frame_rate < MAX_FPS_DELTA: + if detected_rate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._frame_rate: float = frame_rate + self._frame_rate: Fraction = framerate_to_fraction(detected_rate) else: - assert framerate >= MAX_FPS_DELTA - self._frame_rate: 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 @@ -149,12 +179,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> Union[bytes, str]: + def path(self) -> str: """Video path.""" return self._path @property - def name(self) -> Union[bytes, str]: + def name(self) -> str: """Name of the video, without extension.""" return self._name @@ -164,7 +194,7 @@ def is_seekable(self) -> bool: return self._io.seekable() @property - def frame_size(self) -> 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) @@ -174,8 +204,8 @@ def duration(self) -> FrameTimecode: return self.base_timecode + self._duration_frames @property - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction.""" return self._frame_rate @property @@ -184,26 +214,39 @@ 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 - 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. + """Current position within stream as the frame number (CFR-equivalent). + + Will return 0 until the first frame is `read`. For VFR video this is an approximation + derived from PTS * framerate; use `position` for accurate PTS-based timing.""" + 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 - Will return 0 until the first frame is `read`.""" + @property + def time_base(self) -> Fraction | None: if self._frame: - return self.position.frame_num + 1 - return 0 + return self._frame.time_base + return None @property def aspect_ratio(self) -> float: @@ -221,7 +264,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: 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). @@ -239,59 +282,79 @@ def seek(self, target: 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 + target = self.base_timecode + target if target >= 1: target = target - 1 target_pts = self._video_stream.start_time + int( - (self.base_timecode + target).get_seconds() / self._video_stream.time_base + (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, advance=True) + self.read(decode=False) while self.position < target: - if self.read(decode=False, advance=True) is False: + if self.read(decode=False) is False: break 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, advance: bool = True) -> Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ - has_advanced = False - if advance: + 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 - self._frame = next(self._container.decode(video=0)) - except av.error.EOFError: + 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, advance=True) + return self.read(decode) return False except StopIteration: return False - has_advanced = True - if decode: - return self._frame.to_ndarray(format="bgr24") - return has_advanced + 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 @@ -307,6 +370,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 @@ -348,7 +421,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) @@ -358,5 +431,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 new file mode 100644 index 00000000..653c5cf7 --- /dev/null +++ b/scenedetect/common.py @@ -0,0 +1,837 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""``scenedetect.common`` Module + +This module contains common types and functions used throughout PySceneDetect. + +This includes :class:`FrameTimecode` which is used as a way for PySceneDetect to store +frame-accurate timestamps of each cut. This is done by also specifying the video framerate with the +timecode, allowing a frame number to be converted to/from a floating-point number of seconds, or +string in the form `"HH:MM:SS[.nnn]"` where the `[.nnn]` part is optional. + +A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames, +`float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate: + +.. code:: python + + 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 +other operand can also be of the above types: + +.. code:: python + + x = FrameTimecode("00:01:00.000", 10.0) + # Can add int (frames), float (seconds), or str (timecode). + print(x + 10) + print(x + 10.0) + print(x + "00:10:00") + # Same for all comparison operators. + print((x + 10.0) == "00:01:10.000") + + +:class:`FrameTimecode` objects can be added and subtracted, however the current implementation +disallows negative values, and will clamp negative results to 0. + +.. warning:: + + Be careful when subtracting :class:`FrameTimecode` objects or adding negative + amounts of frames/seconds. In the example below, ``c`` will be at frame 0 since + ``b > a``, but ``d`` will be at frame 5: + + .. code:: python + + a = FrameTimecode(5, 10.0) + b = FrameTimecode(10, 10.0) + c = a - b # b > a, so c == 0 + d = b - a + assert(c == 0) + assert(d == 5) +""" + +import math +import typing as ty +import warnings +from dataclasses import dataclass +from enum import Enum +from fractions import Fraction + +import cv2 + +## +## Type Aliases +## + +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. +""" + +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.""" + + NEAREST = cv2.INTER_NEAREST + """Nearest neighbor interpolation.""" + LINEAR = cv2.INTER_LINEAR + """Bilinear interpolation.""" + CUBIC = cv2.INTER_CUBIC + """Bicubic interpolation.""" + AREA = cv2.INTER_AREA + """Pixel area relation resampling. Provides moire'-free downscaling.""" + LANCZOS4 = cv2.INTER_LANCZOS4 + """Lanczos interpolation over 8x8 neighborhood.""" + + +@dataclass(frozen=True) +class Timecode: + """Timing information associated with a given frame.""" + + pts: int + """Presentation timestamp of the frame in units of `time_base`.""" + time_base: Fraction + """The base unit in which `pts` is measured.""" + + @property + def seconds(self) -> float: + return float(self.time_base * self.pts) + + +@dataclass(frozen=True) +class _FrameNumber: + """Represents a time as a frame number.""" + + value: int + + +@dataclass(frozen=True) +class _Seconds: + """Represents a time in seconds.""" + + value: float + + +class FrameTimecode: + """Object for frame-based timecodes, using the video framerate to compute back and + forth between frame number and seconds/timecode. + + A timecode is valid only if it complies with one of the following three types/formats: + 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: "TimecodeLike", + fps: "float | FrameTimecode | Fraction | None" = None, + ): + """ + Arguments: + timecode: A frame number (`int`), number of seconds (`float`), timecode string in + the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`, or a `Timecode`. + fps: The framerate to use for distance between frames and to calculate frame numbers. + For a VFR video, this may just be the average framerate. + Raises: + TypeError: Thrown if either `timecode` or `fps` are unsupported types. + ValueError: Thrown when specifying a negative timecode or framerate. + """ + self._time: _FrameNumber | _Seconds | Timecode + """Internal time representation.""" + self._rate: Fraction | None = None + """Rate at which time passes between frames, measured in frames/sec.""" + + # Copy constructor. + if isinstance(timecode, FrameTimecode): + self._time = timecode._time + self._rate = timecode._rate if fps is None else self._ensure_fractional(fps) + return + + # Ensure args are consistent with API. + if fps is None: + raise TypeError("fps is a required argument.") + self._rate = self._ensure_fractional(fps) + + # Timecode with a time base. + if isinstance(timecode, Timecode): + self._time = timecode + return + + # Process the timecode value, storing it as an exact number of frames only if required. + if isinstance(timecode, str) and timecode.isdigit(): + timecode = int(timecode) + + if isinstance(timecode, str): + self._time = _Seconds(self._timecode_to_seconds(timecode)) + elif isinstance(timecode, float): + if timecode < 0.0: + raise ValueError("Timecode frame number must be positive and greater than zero.") + self._time = _Seconds(timecode) + 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) + + @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): + # Calculate approximate frame number from seconds and framerate. + if self._rate is not None: + return round(self._time.seconds * float(self._rate)) + # No framerate available - return estimate based on time. + return round(self._time.seconds) + if isinstance(self._time, _Seconds): + return self._seconds_to_frames(self._time.value) + return self._time.value + + @property + def 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`. + """ + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning here once internal + # callers and downstream users have had a release to migrate to `frame_rate`. + if self._rate is None: + return None + return float(self._rate) + + @property + 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 + def pts(self) -> int: + """The presentation timestamp of the frame in units of `time_base`.""" + if isinstance(self._time, Timecode): + return self._time.pts + return self.frame_num + + def get_frames(self) -> int: + """[DEPRECATED] Get the current time/position in number of frames. + + Use the `frame_num` property instead. + + :meta private: + """ + warnings.warn( + "get_frames() is deprecated, use the `frame_num` property instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.frame_num + + def get_framerate(self) -> float | None: + """[DEPRECATED] Get Framerate: Returns the framerate used by the FrameTimecode object. + + Use the `framerate` property instead. + + :meta private: + """ + warnings.warn( + "get_framerate() is deprecated, use the `framerate` property instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.framerate + + def equal_frame_rate(self, other: "float | Fraction | FrameTimecode") -> bool: + """Determine whether the passed frame rate equals this object's frame rate. + + Arguments: + 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 ``other`` matches this :class:`FrameTimecode`'s frame rate within + tolerance, False otherwise. + + """ + 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.""" + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning here once internal + # callers and downstream users have had a release to migrate to `equal_frame_rate`. + return self.equal_frame_rate(fps) + + @property + def seconds(self) -> float: + """The frame's position in number of seconds.""" + if isinstance(self._time, Timecode): + 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: + """[DEPRECATED] Get the frame's position in number of seconds. + + Use the `seconds` property instead. + + If using to compare a :class:`FrameTimecode` with a frame number, + you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 1.0``). + + Returns: + float: The current time/position in seconds. + + :meta private: + """ + warnings.warn( + "get_seconds() is deprecated, use the `seconds` property instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.seconds + + # TODO(https://scenedetect.com/issue/168): We should remove `nearest_frame` if possible, it + # assumes constant framerate and causes more problems than it solves. Setting it to False makes + # test_cli_load_scenes_with_time_frames in test_cli.py fail due to differences in end time. + # We may also just need to clamp end time to the one specified by the user, this may not be + # happening in the code. + def get_timecode( + self, precision: int = 3, use_rounding: bool = True, nearest_frame: bool = True + ) -> str: + """Get a formatted timecode string of the form HH:MM:SS[.nnn]. + + 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. + nearest_frame: Ensures that the timecode is moved to the nearest frame boundary if this + object has a defined framerate, otherwise has no effect. + + Returns: + str: The current time in the form ``"HH:MM:SS[.nnn]"``. + """ + # Compute hours and minutes based off of seconds, and update seconds. + # 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) + secs -= hrs * _SECONDS_PER_HOUR + mins = int(secs / _SECONDS_PER_MINUTE) + secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) + if use_rounding: + secs = round(secs, precision) + secs = min(_SECONDS_PER_MINUTE, secs) + # Guard against emitting timecodes with 60 seconds after rounding/floating point errors. + if int(secs) == _SECONDS_PER_MINUTE: + secs = 0.0 + mins += 1 + if mins >= _MINUTES_PER_HOUR: + mins = 0 + hrs += 1 + # We have to extend the precision by 1 here, since `format` will round up. + 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 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 _timecode_to_seconds(self, input: str) -> float: + """Parses a string based on the three possible forms (in timecode format, as an integer + number of frames, or floating-point seconds, ending with 's'). Exact frame numbers (int) + requires the `framerate` property was set when the timecode was created. Assuming a + framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', '9000', '300s', and + '300.0' are all possible valid values. These values represent periods of time equal to + 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). + + Raises: + ValueError: Value could not be parsed correctly. + """ + assert self._rate is not None and self._rate > MAX_FPS_DELTA + input = input.strip() + # Exact number of frames N + if input.isdigit(): + timecode = int(input) + if timecode < 0: + raise ValueError("Timecode frame number must be positive.") + return timecode / float(self._rate) + # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' + elif input.find(":") >= 0: + values = input.split(":") + if len(values) not in (2, 3): + raise ValueError("Invalid timecode (too many separators).") + # Case of 'HH:MM:SS[.nnn]' + if len(values) == 3: + hrs, mins = int(values[0]), int(values[1]) + secs = float(values[2]) if "." in values[2] else int(values[2]) + # Case of 'MM:SS[.nnn]' + elif len(values) == 2: + hrs = 0 + mins = int(values[0]) + secs = float(values[1]) if "." in values[1] else int(values[1]) + if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60): + raise ValueError("Invalid timecode range (values outside allowed range).") + secs += (hrs * 60 * 60) + (mins * 60) + return secs + # Try to parse the number as seconds in the format 1234.5 or 1234s + if input.endswith("s"): + input = input[:-1] + if not input.replace(".", "").isdigit(): + raise ValueError("All characters in timecode seconds string must be digits.") + as_float = float(input) + if as_float < 0.0: + raise ValueError("Timecode seconds value must be positive.") + return as_float + + def _get_other_as_frames(self, other: "TimecodeLike") -> int: + """Get the frame number from `other` for arithmetic operations.""" + if isinstance(other, int): + return other + if isinstance(other, float): + 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_frame_rate(other._rate): + raise ValueError( + "FrameTimecode instances require equal frame rate for frame-based arithmetic." + ) + if isinstance(other._time, _FrameNumber): + return other._time.value + # If other has no frame_num, it must have a timecode. Convert to frames. + return self._seconds_to_frames(other.seconds) + raise TypeError("Cannot obtain frame number for this timecode.") + + def __eq__(self, other: "TimecodeLike") -> bool: + if other is None: + return False + 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): + return self.frame_num == other + if isinstance(self._time, (Timecode, _Seconds)): + return self.seconds == self._get_other_as_seconds(other) + return self.frame_num == self._get_other_as_frames(other) + + def __ne__(self, other: "TimecodeLike") -> bool: + if other is None: + return True + 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): + return self.frame_num != other + if isinstance(self._time, (Timecode, _Seconds)): + return self.seconds != self._get_other_as_seconds(other) + return self.frame_num != self._get_other_as_frames(other) + + 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): + return self.frame_num < other + if isinstance(self._time, (Timecode, _Seconds)): + return self.seconds < self._get_other_as_seconds(other) + return self.frame_num < self._get_other_as_frames(other) + + 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): + return self.frame_num <= other + if isinstance(self._time, (Timecode, _Seconds)): + return self.seconds <= self._get_other_as_seconds(other) + return self.frame_num <= self._get_other_as_frames(other) + + 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): + return self.frame_num > other + if isinstance(self._time, (Timecode, _Seconds)): + return self.seconds > self._get_other_as_seconds(other) + return self.frame_num > self._get_other_as_frames(other) + + 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): + return self.frame_num >= other + if isinstance(self._time, (Timecode, _Seconds)): + return self.seconds >= self._get_other_as_seconds(other) + return self.frame_num >= self._get_other_as_frames(other) + + 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 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): + 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, other_inner.pts + round(self.seconds / other_inner.time_base)), + time_base=other_inner.time_base, + ) + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate + return self + + 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): + self._time = _Seconds(max(0.0, self._time.value + self._get_other_as_seconds(other))) + return self + + self._time = _FrameNumber(max(0, self._time.value + self._get_other_as_frames(other))) + return self + + def __add__(self, other: "TimecodeLike") -> "FrameTimecode": + to_return = FrameTimecode(timecode=self) + to_return += other + return to_return + + 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 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): + 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, self_pts_in_other_base - other_inner.pts), + time_base=other_inner.time_base, + ) + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate + return self + + 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): + self._time = _Seconds(max(0.0, self._time.value - self._get_other_as_seconds(other))) + return self + + self._time = _FrameNumber(max(0, self._time.value - self._get_other_as_frames(other))) + return self + + def __sub__(self, other: "TimecodeLike") -> "FrameTimecode": + to_return = FrameTimecode(timecode=self) + to_return -= other + return to_return + + # TODO(v1.0): __int__ and __float__ should be removed. Mark as deprecated, and indicate + # need to use relevant property instead. + + def __int__(self) -> int: + if isinstance(self._time, _FrameNumber): + return self._time.value + return self.frame_num + + def __float__(self) -> float: + return self.seconds + + def __str__(self) -> str: + return self.get_timecode() + + def __repr__(self) -> str: + if isinstance(self._time, Timecode): + return f"{self.get_timecode()} [pts={self._time.pts}, time_base={self._time.time_base}]" + if isinstance(self._time, _Seconds): + return f"{self.get_timecode()} [seconds={self._time.value}, fps={self._rate}]" + return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]" + + def __hash__(self) -> int: + # 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 (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: "TimecodeLike") -> float: + """Get the time in seconds from `other` for arithmetic operations.""" + if isinstance(other, int): + # Convert frame number to seconds using framerate. + if self._rate is None: + raise NotImplementedError( + "Cannot convert frame number to seconds without framerate" + ) + return float(other) / float(self._rate) + if isinstance(other, float): + return other + if isinstance(other, str): + 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(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 new file mode 100644 index 00000000..e06e1440 --- /dev/null +++ b/scenedetect/detector.py @@ -0,0 +1,224 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""``scenedetect.detector`` Module + +This module contains the :class:`SceneDetector` interface, from which all scene detectors in +:mod:`scenedetect.detectors` module are derived from. + +The SceneDetector class represents the interface which detection algorithms are expected to provide +in order to be compatible with PySceneDetect. + +.. warning:: + + This API is still unstable, and changes and design improvements are planned for the v1.0 + release. Instead of just timecodes, detection algorithms will also provide a specific type of + event (in, out, cut, etc...). +""" + +import math +from abc import ABC, abstractmethod +from enum import Enum + +import numpy + +from scenedetect.common import FrameTimecode, Timecode, TimecodeLike +from scenedetect.stats_manager import StatsManager + + +class SceneDetector(ABC): + """Base class to inherit from when implementing a scene detection algorithm. + + This API is not yet stable and subject to change. + """ + + def __init__(self): + self._stats_manager: StatsManager | None = None + + # Required Methods + + @abstractmethod + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> list[FrameTimecode]: + """Process the next frame. `timecode` is assumed to be sequential. + + Arguments: + timecode: Timecode corresponding to the frame being processed. + frame_img: Video frame as a 24-bit BGR image. + + Returns: + List of timecodes where scene cuts have been detected, if any. + """ + + # Optional Methods + + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: + """Called after there are no more frames to process. + + Arguments: + timecode: The last position in the video which was read. + + Returns: + List of timecodes where scene cuts have been detected, if any. + """ + return [] + + @property + def event_buffer_length(self) -> int: + """The amount of frames a given event can be buffered for, in time. This must be set to the + amount of frames a detector might emit an event in the past.""" + return 0 + + # Frame Stats/Metrics + + @property + 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 + same :class:`StatsManager ` of the parent - but + only if it has one itself.""" + return self._stats_manager + + @stats_manager.setter + def stats_manager(self, value: StatsManager | None): + self._stats_manager = value + + def get_metrics(self) -> list[str]: + """Returns a list of all metric names/keys used by this detector. + + Returns: + List of strings of frame metric key names that will be used by + the detector when a StatsManager is passed to process_frame. + """ + return [] + + +class FlashFilter: + """Filters fast-cuts to enforce minimum scene length.""" + + class Mode(Enum): + """Which mode the filter should use for enforcing minimum scene length.""" + + MERGE = 0 + """Merge consecutive cuts shorter than filter length.""" + SUPPRESS = 1 + """Suppress consecutive cuts until the filter length has passed.""" + + def __init__(self, mode: Mode, length: TimecodeLike): + """ + Arguments: + mode: The mode to use when enforcing `length`. + 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 + # 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: FrameTimecode | None = None # Frame where we started merging. + + @property + def max_behind(self) -> int: + 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) -> list[FrameTimecode]: + if self._is_disabled: + return [timecode] if above_threshold else [] + if self._last_above is None: + self._last_above = timecode + if self._mode == FlashFilter.Mode.MERGE: + return self._filter_merge(timecode=timecode, above_threshold=above_threshold) + elif self._mode == FlashFilter.Mode.SUPPRESS: + return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) + raise RuntimeError("Unhandled FlashFilter mode.") + + def _filter_suppress( + self, timecode: FrameTimecode, above_threshold: bool + ) -> list[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 + # requirements are met again. + self._last_above = timecode + return [timecode] + + 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. + 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. + return [] + # Wait for next frame above the threshold. + if not above_threshold: + return [] + # If we met the minimum length requirement, no merging is necessary. + if min_length_met: + # Only allow the merge filter once the first cut is emitted. + self._merge_enabled = True + return [timecode] + # Start merging cuts until the length requirement is met. + if self._merge_enabled: + self._merge_triggered = True + self._merge_start = timecode + return [] diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index a87a5689..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. # @@ -30,16 +30,16 @@ Uses perceptual hashing to calculate similarity between adjacent frames. Detection algorithms are created by implementing the -:class:`SceneDetector ` interface. Detectors are +:class:`SceneDetector ` interface. Detectors are typically attached to a :class:`SceneManager ` when 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 0cbb4895..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. # @@ -17,10 +17,10 @@ """ from logging import getLogger -from typing import List, Optional import numpy as np +from scenedetect.common import FrameTimecode, TimecodeLike from scenedetect.detectors import ContentDetector logger = getLogger("pyscenedetect") @@ -37,20 +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: Optional[int] = None, - video_manager=None, - min_delta_hsv: Optional[float] = 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: Minimum length of any scene. + 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 @@ -64,16 +64,7 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel to use for post edge detection filtering. If None, automatically set based on video resolution. - video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. - min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead. """ - # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will - # be removed in v0.8. - if video_manager is not None: - logger.error("video_manager is deprecated, use video instead.") - if min_delta_hsv is not None: - logger.error("min_delta_hsv is deprecated, use min_content_val instead.") - min_content_val = min_delta_hsv if window_width < 1: raise ValueError("window_width must be at least 1.") @@ -85,7 +76,7 @@ def __init__( kernel_size=kernel_size, ) - # TODO: Turn these options into properties. + # TODO: Turn these public options into properties. self.min_scene_len = min_scene_len self.adaptive_threshold = adaptive_threshold self.min_content_val = min_content_val @@ -94,53 +85,35 @@ 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._first_frame_num = None - - # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. - self._last_cut: Optional[int] = None - - self._buffer = [] + 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: FrameTimecode | None = None @property def event_buffer_length(self) -> int: - """Number of frames any detected cuts will be behind the current frame due to buffering.""" return self.window_width - def get_metrics(self) -> List[str]: - """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" - return super().get_metrics() + [self._adaptive_ratio_key] - - def stats_manager_required(self) -> bool: - """Not required for AdaptiveDetector.""" - return False - - def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: - """Process the next frame. `frame_num` is assumed to be sequential. - - 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`. - - Returns: - 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. - """ + def get_metrics(self) -> list[str]: + return [*super().get_metrics(), self._adaptive_ratio_key] - # TODO(#283): Merge this with ContentDetector and turn it on by default. + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: + super().process_frame(timecode=timecode, frame_img=frame_img) - super().process_frame(frame_num=frame_num, 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 = frame_num + self._last_cut = timecode required_frames = 1 + (2 * self.window_width) - self._buffer.append((frame_num, self._frame_score)) + self._buffer.append((timecode, self._frame_score)) if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] - (target_frame, target_score) = self._buffer[self.window_width] + (target_timecode, target_score) = self._buffer[self.window_width] average_window_score = sum( score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width ) / (2.0 * self.window_width) @@ -154,30 +127,17 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: - self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio}) + self.stats_manager.set_metrics( + target_timecode, {self._adaptive_ratio_key: adaptive_ratio} + ) # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut threshold_met: bool = ( adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val ) - min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len + min_length_met: bool = (timecode - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: - self._last_cut = target_frame - return [target_frame] - return [] - - def get_content_val(self, frame_num: int) -> Optional[float]: - """Returns the average content change for a frame.""" - # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. - logger.error( - "get_content_val is deprecated and will be removed. Lookup the value" - " using a StatsManager with ContentDetector.FRAME_SCORE_KEY." - ) - if self.stats_manager is not None: - return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] - return 0.0 - - def post_process(self, _unused_frame_num: int): - """Not required for AdaptiveDetector.""" + self._last_cut = target_timecode + return [target_timecode] return [] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 75c06ae9..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. # @@ -16,13 +16,14 @@ """ import math +import typing as ty from dataclasses import dataclass -from typing import List, NamedTuple, Optional import cv2 import numpy -from scenedetect.scene_detector import FlashFilter, SceneDetector +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import FlashFilter, SceneDetector def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: @@ -54,7 +55,7 @@ class ContentDetector(SceneDetector): # TODO: Come up with some good weights for a new default if there is one that can pass # a wider variety of test cases. - class Components(NamedTuple): + class Components(ty.NamedTuple): """Components that make up a frame's score, and their default values.""" delta_hue: float = 1.0 @@ -84,7 +85,7 @@ class Components(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 @@ -97,23 +98,24 @@ class _FrameData: """Frame saturation map [2D 8-bit].""" lum: numpy.ndarray """Frame luma/brightness map [2D 8-bit].""" - edges: 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: 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. + 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. @@ -125,28 +127,24 @@ def __init__( """ super().__init__() self._threshold: float = threshold - self._min_scene_len: int = min_scene_len - self._last_above_threshold: Optional[int] = None - self._last_frame: 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: Optional[numpy.ndarray] = None + self._kernel: numpy.ndarray | None = None if kernel_size is not None: - print(kernel_size) 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: 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) def get_metrics(self): return ContentDetector.METRIC_KEYS - def is_processing_required(self, frame_num): - return True - - def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> float: + def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> float: """Calculate score representing relative amount of motion in `frame_img` compared to the last time the function was called (returns 0.0 on the first call).""" # TODO: Add option to enable motion estimation before calculating score components. @@ -170,42 +168,47 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl 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) ), ) frame_score: float = sum( - component * weight for (component, weight) in zip(score_components, self._weights) + component * weight + for (component, weight) in zip(score_components, self._weights, strict=True) ) / sum(abs(weight) for weight in self._weights) # Record components and frame score if needed for analysis. if self.stats_manager is not None: metrics = {self.FRAME_SCORE_KEY: frame_score} metrics.update(score_components._asdict()) - self.stats_manager.set_metrics(frame_num, metrics) + self.stats_manager.set_metrics(timecode, metrics) # Store all data required to calculate the next frame's score. self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) return frame_score - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> 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`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + 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. """ - self._frame_score = self._calculate_frame_score(frame_num, frame_img) + self._frame_score = self._calculate_frame_score(timecode, frame_img) if self._frame_score is None: return [] above_threshold: bool = self._frame_score >= self._threshold - return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) + return self._flash_filter.filter(timecode=timecode, above_threshold=above_threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 36f7e1b5..395766c9 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -1,44 +1,26 @@ # -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ 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. # -# PySceneDetect is licensed under the BSD 3-Clause License; see the included -# LICENSE file, or visit one of the following pages for details: -# - https://github.com/Breakthrough/PySceneDetect/ -# - http://www.bcastell.com/projects/PySceneDetect/ -# -# This software uses Numpy, OpenCV, click, tqdm, simpletable, and pytest. -# See the included LICENSE files or one of the above URLs for more information. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -# -"""``scenedetect.detectors.hash_detector`` Module +""":py:class:`HashDetector` calculates a hash for each frame of a video using a perceptual +hashing algorithm. The differences (distance) in hash value between frames is calculated. +If this difference exceeds a set threshold, a scene cut is triggered. -This module implements the :py:class:`HashDetector`, which calculates a hash -value for each from of a video using a perceptual hashing algorithm. Then, the -differences in hash value between frames is calculated. If this difference -exceeds a set threshold, a scene cut is triggered. - -This detector is available from the command-line interface by using the -`detect-hash` command. +This detector is available from the command-line interface by using the `detect-hash` command. """ -# Third-Party Library Imports import cv2 import numpy -# PySceneDetect Library Imports -from scenedetect.scene_detector import SceneDetector +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import SceneDetector class HashDetector(SceneDetector): @@ -57,55 +39,44 @@ 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: Minimum length of any given scene, in frames (int) or FrameTimecode + 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 = None - self._last_scene_cut = 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}]" def get_metrics(self): return [self._metric_key] - def is_processing_required(self, frame_num): - return True - - def process_frame(self, frame_num, frame_img): + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> 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. - - Arguments: - frame_num (int): Frame number of frame that is being passed. - - frame_img (Optional[int]): Decoded frame image (numpy.ndarray) to perform scene - detection on. Can be None *only* if the self.is_processing_required() method - (inhereted from the base SceneDetector class) returns True. - - Returns: - 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. - """ + frame to frame.""" cut_list = [] # 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 # We can only start detecting once we have a frame to compare with. if self._last_frame is not None: @@ -129,17 +100,17 @@ def process_frame(self, frame_num, frame_img): hash_dist_norm = hash_dist / self._size_sq if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_key: hash_dist_norm}) + self.stats_manager.set_metrics(timecode, {self._metric_key: hash_dist_norm}) self._last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). if hash_dist_norm >= self._threshold and ( - (frame_num - self._last_scene_cut) >= self._min_scene_len + (timecode - self._last_scene_cut) >= self._min_scene_len ): - cut_list.append(frame_num) - self._last_scene_cut = frame_num + cut_list.append(timecode) + self._last_scene_cut = timecode self._last_frame = frame_img.copy() @@ -165,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 9e37df09..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. # @@ -15,30 +15,36 @@ This detector is available from the command-line as the `detect-hist` command. """ -from typing import List +import typing as ty import cv2 import numpy -# PySceneDetect Library Imports -from scenedetect.scene_detector import SceneDetector +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import SceneDetector 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: Minimum length of any scene. + 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 @@ -47,23 +53,25 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int self._bins = bins self._min_scene_len = min_scene_len self._last_hist = None - self._last_scene_cut = None + self._last_cut = None self._metric_key = f"hist_diff [bins={self._bins}]" - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> 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 = [] @@ -76,8 +84,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: raise ValueError("Image must have three color channels for HistogramDetector") # Initialize last scene cut point at the beginning of the frames of interest. - if not self._last_scene_cut: - self._last_scene_cut = frame_num + if not self._last_cut: + self._last_cut = timecode hist = self.calculate_histogram(frame_img, bins=self._bins) @@ -91,20 +99,21 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # 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 ( - (frame_num - self._last_scene_cut) >= self._min_scene_len + (timecode - self._last_cut) >= self._min_scene_len ): - cut_list.append(frame_num) - self._last_scene_cut = frame_num + cut_list.append(timecode) + self._last_cut = timecode # Save stats to a StatsManager if it is being used if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_key: hist_diff}) + self.stats_manager.set_metrics(timecode, {self._metric_key: hist_diff}) self._last_hist = hist @@ -122,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)) @@ -159,8 +164,5 @@ def calculate_histogram( return hist - def is_processing_required(self, frame_num: int) -> bool: - return True - - def get_metrics(self) -> 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 f14d1882..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. # @@ -16,41 +16,17 @@ """ import typing as ty +import warnings from enum import Enum from logging import getLogger import numpy -from scenedetect.scene_detector import SceneDetector +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import SceneDetector logger = getLogger("pyscenedetect") -## -## ThresholdDetector Helper Functions -## - - -def _compute_frame_average(frame: numpy.ndarray) -> float: - """Computes the average pixel value/intensity for all pixels in a frame. - - The value is computed by adding up the 8-bit R, G, and B values for - each pixel, and dividing by the number of pixels multiplied by 3. - - Arguments: - frame: Frame representing the RGB pixels to average. - - Returns: - Average pixel intensity across all 3 channels of `frame` - """ - num_pixel_values = float(frame.shape[0] * frame.shape[1] * frame.shape[2]) - avg_pixel_value = numpy.sum(frame[:, :, :]) / num_pixel_values - return avg_pixel_value - - -## -## ThresholdDetector Class Implementation -## - class ThresholdDetector(SceneDetector): """Detects fast cuts/slow fades in from and out to a given threshold level. @@ -72,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, @@ -82,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: FrameTimecode object or integer greater than 0 of the - minimum length, in frames, of a scene (or subsequent scene cut). + 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 @@ -93,9 +70,12 @@ def __init__( method: How to treat `threshold` when detecting fade events. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. """ - # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. if block_size is not None: - logger.error("block_size is deprecated.") + warnings.warn( + "The `block_size` argument is deprecated and will be removed in v0.8.", + DeprecationWarning, + stacklevel=2, + ) super().__init__() self.threshold = int(threshold) @@ -103,56 +83,50 @@ 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] - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: - """Process the next frame. `frame_num` is assumed to be sequential. + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> 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. """ - # Initialize last scene cut point at the beginning of the frames of interest. if self.last_scene_cut is None: - self.last_scene_cut = frame_num - - # Compare the # of pixels under threshold in current_frame & last_frame. - # If absolute value of pixel intensity delta is above the threshold, - # then we trigger a new scene cut/break. + self.last_scene_cut = timecode - # List of cuts to return. - cut_list = [] + 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 = _compute_frame_average(frame_img) + frame_avg = numpy.mean(frame_img) if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) + self.stats_manager.set_metrics(timecode, {self._metric_keys[0]: frame_avg}) if self.processed_frame: if self.last_fade["type"] == "in" and ( @@ -161,34 +135,39 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int ): # 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 ) - cut_list.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 cut_list + return cuts - def post_process(self, frame_num: int): + 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 @@ -200,14 +179,13 @@ def post_process(self, frame_num: int): # 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. - cut_times = [] + 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 frame_num >= self.min_scene_len) - or (frame_num - self.last_scene_cut) >= self.min_scene_len - ) + and self.last_fade["frame"] is not None + and elapsed >= self.min_scene_len ): - cut_times.append(self.last_fade["frame"]) - return cut_times + cuts.append(self.last_fade["frame"]) + return cuts diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py new file mode 100644 index 00000000..726b4d2d --- /dev/null +++ b/scenedetect/detectors/transnet_v2.py @@ -0,0 +1,210 @@ +# +# 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. +# +""":class:`TransnetV2Detector` uses a pretrained neural network. + +This detector is available from the command-line as the `detect-transnetv2` command. +""" + +from logging import getLogger +from pathlib import Path + +import cv2 +import numpy as np + +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import FlashFilter, SceneDetector + +logger = getLogger("pyscenedetect") + + +class Detector: + def __init__(self, threshold: float, flash_filter: FlashFilter): + self.i = 0 + self.y_prev = 0 + self.threshold = threshold + self.flash_filter = flash_filter + + def push(self, ys: np.ndarray, ts: np.ndarray): + predictions = (ys > self.threshold).astype(np.uint8) + + cuts = [] + for y, t in zip(predictions, ts, strict=True): + if self.y_prev == 0 and y == 1 and self.i > 0: + cuts.append(t) + self.y_prev = y + self.i += 1 + + return cuts + + +class Predictor: + def __init__( + self, + model_path: str | Path, + flash_filter: FlashFilter, + onnx_providers: list[str] | None, + threshold, + ): + import onnxruntime as ort # pyright: ignore[reportMissingImports] + + ort.set_default_logger_severity(3) + + if onnx_providers is None: + onnx_providers = ort.get_available_providers() + + sess_opt = ort.SessionOptions() + sess_opt.log_severity_level = 3 + + self.session = ort.InferenceSession(model_path, sess_opt=sess_opt, providers=onnx_providers) + + self.pixels = None + self.time = None + + self.det = Detector(threshold, flash_filter) + + def _inference(self, pixels: np.ndarray, time: np.ndarray): + pred = np.array(self.session.run(["output"], {"input": pixels}))[0] + + cuts = [] + for i in range(pred.shape[0]): + cuts.extend(self.det.push(pred[i, 25:75, 0], time[i, 25:75])) + return cuts + + def push(self, pixels: np.ndarray, time: np.ndarray): + if self.pixels is None: + self.pixels = pixels + self.time = time + + return self._inference( + np.stack( + ( + np.tile(np.expand_dims(pixels[0], axis=0), (100, 1, 1, 1)), + np.concatenate( + ( + np.tile(np.expand_dims(pixels[0], axis=0), (25, 1, 1, 1)), + pixels[:75], + ), + 0, + ), + ) + ), + np.stack( + ( + np.tile(np.expand_dims(time[0], axis=0), (100,)), + np.concatenate( + (np.tile(np.expand_dims(time[0], axis=0), (25,)), time[:75]), 0 + ), + ) + ), + ) + else: + # `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 + + t1 = self.time + t2 = time + + self.pixels = pixels + self.time = time + + return self._inference( + np.stack( + (np.concatenate((c1[25:], c2[:25]), 0), np.concatenate((c1[75:], c2[:75]), 0)) + ), + np.stack( + (np.concatenate((t1[25:], t2[:25]), 0), np.concatenate((t1[75:], t2[:75]), 0)) + ), + ) + + +class TransnetV2Detector(SceneDetector): + def __init__( + self, + model_path: str | Path = "tests/resources/transnetv2.onnx", + onnx_providers: list[str] | None = None, + threshold: float = 0.5, + min_scene_len: TimecodeLike = 15, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, + ): + super().__init__() + + self.px = np.zeros((2, 100, 27, 48, 3), dtype=np.uint8) + self.time = np.zeros((2, 100), dtype=np.int64) + + self.blank = np.zeros(self.px.shape[2:], dtype=np.uint8) + + self.i = 0 + self.j = 0 + + self.predictor = Predictor( + model_path=model_path, + flash_filter=FlashFilter(mode=filter_mode, length=min_scene_len), + onnx_providers=onnx_providers, + threshold=threshold, + ) + # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling + # `min_scene_len` which should be specified in seconds, not frames. + self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) + + def mk_ft(self, pts: int): + # t = Timecode(pts=pts, time_base=self.time_base) + t = float(pts * self.time_base) + return FrameTimecode(t, fps=self._fps) + + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: + """Process the next frame.""" + + self.time_base = timecode.time_base + self._fps = timecode._rate + + pixels = cv2.resize(frame_img, (48, 27), interpolation=cv2.INTER_AREA) + + self.px[self.j, self.i] = pixels + self.time[self.j, self.i] = timecode.pts + self.i += 1 + + if self.i >= 100: + cuts = self.predictor.push(self.px[self.j], self.time[self.j]) + self.j = 1 - self.j + self.i = 0 + + filtered_cuts = [] + for cut in cuts: + filtered_cuts += self._flash_filter.filter(self.mk_ft(cut), True) + return filtered_cuts + else: + return [] + + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: + """Writes a final scene cut if the last detected fade was a fade-out.""" + + cuts = [] + + last_time = timecode.pts + blank_frame = self.blank[:] + + self.px[self.j, self.i :] = blank_frame + self.time[self.j, self.i :] = last_time + cuts.extend(self.predictor.push(self.px[self.j], self.time[self.j])) + + self.j = 1 - self.j + + self.px[self.j, :] = blank_frame + self.time[self.j, :] = last_time + cuts.extend(self.predictor.push(self.px[self.j], self.time[self.j])) + + filtered_cuts = [] + for cut in cuts: + filtered_cuts += self._flash_filter.filter(self.mk_ft(cut), True) + return filtered_cuts diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 958c1cd2..8fcb5e15 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -5,474 +5,18 @@ # [ 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. # -"""``scenedetect.frame_timecode`` Module +"""DEPRECATED""" -This module implements :class:`FrameTimecode` which is used as a way for PySceneDetect to store -frame-accurate timestamps of each cut. This is done by also specifying the video framerate with the -timecode, allowing a frame number to be converted to/from a floating-point number of seconds, or -string in the form `"HH:MM:SS[.nnn]"` where the `[.nnn]` part is optional. +import warnings -See the following examples, or the :class:`FrameTimecode constructor `. +warnings.warn( + "The `frame_timecode` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) -=============================================================== -Usage Examples -=============================================================== - -A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames, -`float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate: - -.. 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) - - -Arithmetic/comparison operations with :class:`FrameTimecode` objects is also possible, and the -other operand can also be of the above types: - -.. code:: python - - x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0) - # Can add int (frames), float (seconds), or str (timecode). - print(x + 10) - print(x + 10.0) - print(x + "00:10:00") - # Same for all comparison operators. - print((x + 10.0) == "00:01:10.000") - - -:class:`FrameTimecode` objects can be added and subtracted, however the current implementation -disallows negative values, and will clamp negative results to 0. - -.. warning:: - - Be careful when subtracting :class:`FrameTimecode` objects or adding negative - amounts of frames/seconds. In the example below, ``c`` will be at frame 0 since - ``b > a``, but ``d`` will be at frame 5: - - .. code:: python - - a = FrameTimecode(5, 10.0) - b = FrameTimecode(10, 10.0) - c = a - b # b > a, so c == 0 - d = b - a - assert(c == 0) - assert(d == 5) - -""" - -import math -from typing import Union - -MAX_FPS_DELTA: float = 1.0 / 100000 -"""Maximum amount two framerates can differ by for equality testing.""" - -_SECONDS_PER_MINUTE = 60.0 -_SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE -_MINUTES_PER_HOUR = 60.0 - - -class FrameTimecode: - """Object for frame-based timecodes, using the video framerate to compute back and - forth between frame number and seconds/timecode. - - A timecode is valid only if it complies with one of the following three types/formats: - 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"`) - """ - - def __init__( - self, - timecode: Union[int, float, str, "FrameTimecode"] = None, - fps: Union[int, float, str, "FrameTimecode"] = None, - ): - """ - Arguments: - timecode: A frame number (int), number of seconds (float), or timecode (str in - the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`). - fps: The framerate or FrameTimecode to use as a time base for all arithmetic. - Raises: - TypeError: Thrown if either `timecode` or `fps` are unsupported types. - ValueError: Thrown when specifying a negative timecode or framerate. - """ - # The following two properties are what is used to keep track of time - # in a frame-specific manner. Note that once the framerate is set, - # the value should never be modified (only read if required). - # TODO(v1.0): Make these actual @properties. - self.framerate = None - self.frame_num = None - - # Copy constructor. Only the timecode argument is used in this case. - if isinstance(timecode, FrameTimecode): - self.framerate = timecode.framerate - self.frame_num = timecode.frame_num - if fps is not None: - raise TypeError("Framerate cannot be overwritten when copying a FrameTimecode.") - else: - # Ensure other arguments are consistent with API. - if fps is None: - raise TypeError("Framerate (fps) is a required argument.") - if isinstance(fps, FrameTimecode): - fps = fps.framerate - - # Process the given framerate, if it was not already set. - if not isinstance(fps, (int, float)): - raise TypeError("Framerate must be of type int/float.") - if (isinstance(fps, int) and not fps > 0) or ( - isinstance(fps, float) and not fps >= MAX_FPS_DELTA - ): - raise ValueError("Framerate must be positive and greater than zero.") - self.framerate = float(fps) - - # Process the timecode value, storing it as an exact number of frames. - if isinstance(timecode, str): - self.frame_num = self._parse_timecode_string(timecode) - else: - self.frame_num = self._parse_timecode_number(timecode) - - # TODO(v1.0): Add a `frame` property to replace the existing one and deprecate this getter. - def get_frames(self) -> int: - """Get the current time/position in number of frames. This is the - equivalent of accessing the self.frame_num property (which, along - with the specified framerate, forms the base for all of the other - time measurement calculations, e.g. the :meth:`get_seconds` method). - - If using to compare a :class:`FrameTimecode` with a frame number, - you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 10``). - - Returns: - int: The current time in frames (the current frame number). - """ - return self.frame_num - - # TODO(v1.0): Add a `framerate` property to replace the existing one and deprecate this getter. - def get_framerate(self) -> float: - """Get Framerate: Returns the framerate used by the FrameTimecode object. - - Returns: - float: Framerate of the current FrameTimecode object, in frames per second. - """ - return self.framerate - - def equal_framerate(self, fps) -> bool: - """Equal Framerate: Determines if the passed framerate is equal to that of this object. - - Arguments: - fps: Framerate to compare against within the precision constant defined in this module - (see :data:`MAX_FPS_DELTA`). - Returns: - bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. - - """ - return math.fabs(self.framerate - fps) < MAX_FPS_DELTA - - # TODO(v1.0): Add a `seconds` property to replace this and deprecate the existing one. - def get_seconds(self) -> float: - """Get the frame's position in number of seconds. - - If using to compare a :class:`FrameTimecode` with a frame number, - you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 1.0``). - - Returns: - float: The current time/position in seconds. - """ - return float(self.frame_num) / self.framerate - - # TODO(v1.0): Add a `timecode` property to replace this and deprecate the existing one. - def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: - """Get a formatted timecode string of the form HH:MM:SS[.nnn]. - - Args: - precision: The number of decimal places to include in the output ``[.nnn]``. - use_rounding: Rounds the output to the desired precision. If False, the value - will be truncated to the specified precision. - - Returns: - str: The current time in the form ``"HH:MM:SS[.nnn]"``. - """ - # Compute hours and minutes based off of seconds, and update seconds. - secs = self.get_seconds() - hrs = int(secs / _SECONDS_PER_HOUR) - secs -= hrs * _SECONDS_PER_HOUR - mins = int(secs / _SECONDS_PER_MINUTE) - secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) - if use_rounding: - secs = round(secs, precision) - secs = min(_SECONDS_PER_MINUTE, secs) - # Guard against emitting timecodes with 60 seconds after rounding/floating point errors. - if int(secs) == _SECONDS_PER_MINUTE: - secs = 0.0 - mins += 1 - if mins >= _MINUTES_PER_HOUR: - 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 "" - # 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) - - # TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter. - def previous_frame(self) -> "FrameTimecode": - """Return a new FrameTimecode for the previous frame (or 0 if on frame 0).""" - new_timecode = FrameTimecode(self) - new_timecode.frame_num = max(0, new_timecode.frame_num - 1) - return new_timecode - - def _seconds_to_frames(self, seconds: float) -> int: - """Convert the passed value seconds to the nearest number of frames using - the current FrameTimecode object's FPS (self.framerate). - - Returns: - Integer number of frames the passed number of seconds represents using - the current FrameTimecode's framerate property. - """ - return round(seconds * self.framerate) - - def _parse_timecode_number(self, timecode: 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) - # FrameTimecode - elif isinstance(timecode, FrameTimecode): - return timecode.frame_num - elif timecode is None: - raise TypeError("Timecode/frame number must be specified!") - else: - raise TypeError("Timecode format/type unrecognized.") - - def _parse_timecode_string(self, input: str) -> int: - """Parses a string based on the three possible forms (in timecode format, - as an integer number of frames, or floating-point seconds, ending with 's'). - - Requires that the `framerate` property is set before calling this method. - Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', - '9000', '300s', and '300.0' are all possible valid values, all representing - a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). - - Raises: - ValueError: Value could not be parsed correctly. - """ - assert self.framerate is not None - input = input.strip() - # Exact number of frames N - if input.isdigit(): - timecode = int(input) - if timecode < 0: - raise ValueError("Timecode frame number must be positive.") - return timecode - # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' - elif input.find(":") >= 0: - values = input.split(":") - # Case of 'HH:MM:SS[.nnn]' - if len(values) == 3: - hrs, mins = int(values[0]), int(values[1]) - secs = float(values[2]) if "." in values[2] else int(values[2]) - # Case of 'MM:SS[.nnn]' - elif len(values) == 2: - hrs = 0 - mins = int(values[0]) - secs = float(values[1]) if "." in values[1] else int(values[1]) - if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60): - raise ValueError("Invalid timecode range (values outside allowed range).") - secs += (hrs * 60 * 60) + (mins * 60) - return self._seconds_to_frames(secs) - # Try to parse the number as seconds in the format 1234.5 or 1234s - if input.endswith("s"): - input = input[:-1] - if not input.replace(".", "").isdigit(): - raise ValueError("All characters in timecode seconds string must be digits.") - as_float = float(input) - if as_float < 0.0: - raise ValueError("Timecode seconds value must be positive.") - return self._seconds_to_frames(as_float) - - def __iadd__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if isinstance(other, int): - self.frame_num += other - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - self.frame_num += other.frame_num - else: - raise ValueError("FrameTimecode instances require equal framerate for addition.") - # Check if value to add is in number of seconds. - elif isinstance(other, float): - self.frame_num += self._seconds_to_frames(other) - elif isinstance(other, str): - self.frame_num += self._parse_timecode_string(other) - else: - raise TypeError("Unsupported type for performing addition with FrameTimecode.") - if self.frame_num < 0: # Required to allow adding negative seconds/frames. - self.frame_num = 0 - return self - - def __add__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - to_return = FrameTimecode(timecode=self) - to_return += other - return to_return - - def __isub__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if isinstance(other, int): - self.frame_num -= other - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - self.frame_num -= other.frame_num - else: - raise ValueError("FrameTimecode instances require equal framerate for subtraction.") - # Check if value to add is in number of seconds. - elif isinstance(other, float): - self.frame_num -= self._seconds_to_frames(other) - elif isinstance(other, str): - self.frame_num -= self._parse_timecode_string(other) - else: - raise TypeError( - "Unsupported type for performing subtraction with FrameTimecode: %s" % type(other) - ) - if self.frame_num < 0: - self.frame_num = 0 - return self - - def __sub__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - to_return = FrameTimecode(timecode=self) - to_return -= other - return to_return - - def __eq__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if isinstance(other, int): - return self.frame_num == other - elif isinstance(other, float): - return self.get_seconds() == other - elif isinstance(other, str): - return self.frame_num == self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - return self.frame_num == other.frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - elif other is None: - return False - else: - raise TypeError( - "Unsupported type for performing == with FrameTimecode: %s" % type(other) - ) - - def __ne__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: - return not self == other - - def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self.frame_num < other - elif isinstance(other, float): - return self.get_seconds() < other - elif isinstance(other, str): - return self.frame_num < self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - return self.frame_num < other.frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing < with FrameTimecode: %s" % type(other) - ) - - def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self.frame_num <= other - elif isinstance(other, float): - return self.get_seconds() <= other - elif isinstance(other, str): - return self.frame_num <= self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - return self.frame_num <= other.frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing <= with FrameTimecode: %s" % type(other) - ) - - def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self.frame_num > other - elif isinstance(other, float): - return self.get_seconds() > other - elif isinstance(other, str): - return self.frame_num > self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - return self.frame_num > other.frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing > with FrameTimecode: %s" % type(other) - ) - - def __ge__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self.frame_num >= other - elif isinstance(other, float): - return self.get_seconds() >= other - elif isinstance(other, str): - return self.frame_num >= self._parse_timecode_string(other) - elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - return self.frame_num >= other.frame_num - else: - raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) - else: - raise TypeError( - "Unsupported type for performing >= with FrameTimecode: %s" % type(other) - ) - - # TODO(v1.0): __int__ and __float__ should be removed. Mark as deprecated, and indicate - # need to use relevant property instead. - - def __int__(self) -> int: - return self.frame_num - - def __float__(self) -> float: - return self.get_seconds() - - def __str__(self) -> str: - return self.get_timecode() - - def __repr__(self) -> str: - return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self.frame_num, self.framerate) - - def __hash__(self) -> int: - return self.frame_num +from scenedetect.common import * # noqa: E402, F403 diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py new file mode 100644 index 00000000..6fa26585 --- /dev/null +++ b/scenedetect/output/__init__.py @@ -0,0 +1,674 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# + +"""The ``scenedetect.output`` module contains functions which can be used to generate output +based on the output of scene detection. This includes saving images for each scene, exporting to +CSV/HTML, or splitting the input video into individual shots. +""" + +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, + SimpleTable, + SimpleTableCell, + SimpleTableImage, + SimpleTableRow, +) +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 as save_images +from scenedetect.output.video import ( + 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") + + +def write_scene_list( + output_csv_file: ty.TextIO, + scene_list: SceneList, + include_cut_list: bool = True, + cut_list: CutList | None = None, + col_separator: str = ",", + row_separator: str = "\n", +): + """Writes the given list of scenes to an output file handle in CSV format. + + Arguments: + output_csv_file: Handle to open file in write mode. + scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. + include_cut_list: Bool indicating if the first row should include the timecodes where + each scene starts. Should be set to False if RFC 4180 compliant CSV output is required. + cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames + in the video that need to be split to generate individual scenes). If not specified, + the cut list is generated using the start times of each scene following the first one. + col_separator: Delimiter to use between values. Must be single character. + row_separator: Line terminator to use between rows. + + Raises: + TypeError: "delimiter" must be a 1-character string + """ + csv_writer = csv.writer(output_csv_file, delimiter=col_separator, lineterminator=row_separator) + # 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] + if cut_list + else [start.get_timecode() for start, _ in scene_list[1:]] + ) + csv_writer.writerow( + [ + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", + ] + ) + for i, (start, end) in enumerate(scene_list): + duration = end - start + csv_writer.writerow( + [ + f"{i + 1:d}", + f"{start.frame_num + 1:d}", + start.get_timecode(), + f"{start.seconds:.3f}", + f"{end.frame_num:d}", + end.get_timecode(), + f"{end.seconds:.3f}", + f"{duration.frame_num:d}", + duration.get_timecode(), + f"{duration.seconds:.3f}", + ] + ) + + +def write_scene_list_html( + output_html_filename: str, + scene_list: SceneList, + cut_list: CutList | None = None, + css: str | None = None, + css_class: str = "mytable", + 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. + + Arguments: + output_html_filename: filename of output html file + scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. + cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames + in the video that need to be split to generate individual scenes). If not passed, + the start times of each scene (besides the 0th scene) is used instead. + css: String containing all the css information for the resulting html page. + css_class: String containing the named css class + image_filenames: dict where key i contains a list with n elements (filenames of + the n saved images from that scene) + image_width: Optional desired width of images in table in pixels + image_height: Optional desired height of images in table in pixels + """ + logger.info("Exporting scenes to html:\n %s:", output_html_filename) + if not css: + css = """ + table.mytable { + font-family: times; + font-size:12px; + color:#000000; + border-width: 1px; + border-color: #eeeeee; + border-collapse: collapse; + background-color: #ffffff; + width=100%; + max-width:550px; + table-layout:fixed; + } + table.mytable th { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + background-color: #e6eed6; + color:#000000; + } + table.mytable td { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + } + #code { + display:inline; + font-family: courier; + color: #3d9400; + } + #string { + display:inline; + font-weight: bold; + } + """ + + # Output Timecode list + timecode_table = SimpleTable( + [ + ["Timecode List:"] + + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) + ], + css_class=css_class, + ) + + # Output list of scenes + header_row = [ + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", + ] + for i, (start, end) in enumerate(scene_list): + duration = end - start + + row = SimpleTableRow( + [ + f"{i + 1:d}", + f"{start.frame_num + 1:d}", + start.get_timecode(), + f"{start.seconds:.3f}", + f"{end.frame_num:d}", + end.get_timecode(), + f"{end.seconds:.3f}", + f"{duration.frame_num:d}", + duration.get_timecode(), + f"{duration.seconds:.3f}", + ] + ) + + if image_filenames: + for image in image_filenames[i]: + row.add_cell( + SimpleTableCell(SimpleTableImage(image, width=image_width, height=image_height)) + ) + + if i == 0: + scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) + else: + scene_table.add_row(row=row) + + # Write html file + page = HTMLPage() + page.add_table(timecode_table) + 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 new file mode 100644 index 00000000..b7fced6a --- /dev/null +++ b/scenedetect/output/image.py @@ -0,0 +1,535 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""Implements :func:`save_images` functionality.""" + +import logging +import math +import queue +import sys +import threading +import typing as ty +from pathlib import Path +from string import Template + +import cv2 +import numpy as np + +from scenedetect.common import ( + FrameTimecode, + Interpolation, + SceneList, + TimecodeLike, +) +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 | None, + height: int | None, + width: int | None, + scale: float | None, + interpolation: Interpolation, +) -> np.ndarray: + # TODO: Combine this resize with the ones below. + if aspect_ratio is not None: + image = cv2.resize( + image, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value + ) + image_height = image.shape[0] + image_width = image.shape[1] + + # Figure out what kind of resizing needs to be done + if height or width: + if height and not width: + factor = height / float(image_height) + width = int(factor * image_width) + 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: + image = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) + return image + + +class _ImageExtractor: + def __init__( + self, + num_images: int = 3, + frame_margin: TimecodeLike = 1, + image_extension: str = "jpg", + imwrite_param: list[int] | None = None, + image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + scale: float | None = None, + height: int | None = None, + width: int | None = None, + interpolation: Interpolation = Interpolation.CUBIC, + ): + """Multi-threaded implementation of save-images functionality. Uses background threads to + handle image encoding and saving images to disk to improve parallelism. + + This object is thread-safe. + + Arguments: + num_images: Number of images to generate for each scene. Minimum is 1. + 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. + 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. + image_name_template: Template to use for output filanames. Can use template variables + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. + *NOTE*: Should not include the image extension (set `image_extension` instead). + scale: Optional factor by which to rescale saved images. A scaling factor of 1 would + not result in rescaling. A value < 1 results in a smaller saved image, while a + value > 1 results in an image larger than the original. This value is ignored if + either the height or width values are specified. + height: Optional value for the height of the saved images. Specifying both the height + and width will resize images to an exact size, regardless of aspect ratio. + Specifying only height will rescale the image to that number of pixels in height + while preserving the aspect ratio. + width: Optional value for the width of the saved images. Specifying both the width + and height will resize images to an exact size, regardless of aspect ratio. + Specifying only width will rescale the image to that number of pixels wide + while preserving the aspect ratio. + interpolation: Type of interpolation to use when resizing images. + """ + self._num_images = num_images + self._frame_margin = frame_margin + self._image_extension = image_extension + self._image_name_template = image_name_template + self._scale = scale + self._height = height + self._width = width + self._interpolation = interpolation + self._imwrite_param: list[int] = imwrite_param if imwrite_param is not None else [] + + def run( + self, + video: VideoStream, + scene_list: SceneList, + output_dir: StrPath | None = None, + show_progress=False, + ) -> dict[int, list[str]]: + """Run image extraction on `video` using the current parameters. Thread-safe. + + Arguments: + video: The video to process. + scene_list: The scenes detected in the video. + output_dir: Directory to write files to. + show_progress: If `true` and tqdm is available, shows a progress bar. + """ + # Setup flags and init progress bar if available. + completed = True + logger.info( + 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: + progress_bar = tqdm( + total=len(scene_list) * self._num_images, unit="images", dynamic_ncols=True + ) + + timecode_list = self.generate_timecode_list(scene_list) + image_filenames = {i: [] for i in range(len(timecode_list))} + + filename_template = Template(self._image_name_template) + logger.debug("Writing images with template %s", filename_template.template) + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + 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 "{}.{}".format( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (scene_number + 1), + IMAGE_NUMBER=image_num_format % (image_number + 1), + FRAME_NUMBER=image_timecode.frame_num, + TIMESTAMP_MS=int(image_timecode.seconds * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), + self._image_extension, + ) + + MAX_QUEUED_ENCODE_FRAMES = 4 + MAX_QUEUED_SAVE_IMAGES = 4 + encode_queue = queue.Queue(MAX_QUEUED_ENCODE_FRAMES) + save_queue = queue.Queue(MAX_QUEUED_SAVE_IMAGES) + error_queue = queue.Queue(2) # Queue size must be the same as the # of worker threads! + + def check_error_queue(): + try: + return error_queue.get(block=False) + except queue.Empty: + pass + return None + + def launch_thread(callable, *args, **kwargs): + def capture_errors(callable, *args, **kwargs): + try: + return callable(*args, **kwargs) + # Errors we capture in `error_queue` will be re-raised by this thread. + except: # noqa: E722 + error_queue.put(sys.exc_info()) + return None + + thread = threading.Thread( + target=capture_errors, + args=( + callable, + *args, + ), + kwargs=kwargs, + daemon=True, + ) + thread.start() + return thread + + def checked_put(work_queue: queue.Queue, item: ty.Any): + error = None + while True: + try: + work_queue.put(item, timeout=0.1) + return + except queue.Full: + error = check_error_queue() + if error is not None: + break + continue + raise error[1].with_traceback(error[2]) + + encode_thread = launch_thread( + self.image_encode_thread, + video, + encode_queue, + save_queue, + ) + save_thread = launch_thread(self.image_save_thread, save_queue, progress_bar) + + for i, scene_timecodes in enumerate(timecode_list): + for j, timecode in enumerate(scene_timecodes): + video.seek(timecode) + frame_im = video.read() + if frame_im is not None and frame_im is not False: + file_path = format_filename(i, j, timecode) + image_filenames[i].append(file_path) + checked_put( + encode_queue, (frame_im, get_and_create_path(file_path, output_dir)) + ) + else: + completed = False + break + + checked_put(encode_queue, (None, None)) + encode_thread.join() + checked_put(save_queue, (None, None)) + save_thread.join() + + error = check_error_queue() + if error is not None: + raise error[1].with_traceback(error[2]) + + if progress_bar is not None: + progress_bar.close() + if not completed: + logger.error("Could not generate all output images.") + + return image_filenames + + def image_encode_thread( + self, + video: VideoStream, + encode_queue: queue.Queue, + save_queue: queue.Queue, + ): + aspect_ratio = video.aspect_ratio + if abs(aspect_ratio - 1.0) < 0.01: + aspect_ratio = None + # 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. + while True: + frame_im, dest_path = encode_queue.get() + if frame_im is None: + return + frame_im = self.resize_image( + frame_im, + aspect_ratio, + ) + (is_ok, encoded) = cv2.imencode( + f".{self._image_extension}", frame_im, self._imwrite_param + ) + if not is_ok: + continue + save_queue.put((encoded, dest_path)) + + def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): + while True: + encoded, dest_path = save_queue.get() + if encoded is None: + return + if encoded is not False: + encoded.tofile(Path(dest_path)) + if progress_bar is not None: + progress_bar.update(1) + + 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. + + 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 | None, + ) -> np.ndarray: + return _scale_image( + image, aspect_ratio, self._height, self._width, self._scale, self._interpolation + ) + + +def save_images( + scene_list: SceneList, + video: VideoStream, + num_images: int = 3, + 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: 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, +) -> dict[int, list[str]]: + """Save a set number of images from each scene, given a list of scenes + and the associated video/frame source. + + Arguments: + scene_list: A list of scenes (pairs of FrameTimecode objects) returned + from calling a SceneManager's detect_scenes() method. + 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: 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. + 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. + image_name_template: Template to use for naming image files. Can use the template variables + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. + Should not include an extension. + output_dir: Directory to output the images into. If not set, the output + is created in the working directory. + show_progress: If True, shows a progress bar if tqdm is installed. + scale: Optional factor by which to rescale saved images. A scaling factor of 1 would + not result in rescaling. A value < 1 results in a smaller saved image, while a + value > 1 results in an image larger than the original. This value is ignored if + either the height or width values are specified. + height: Optional value for the height of the saved images. Specifying both the height + and width will resize images to an exact size, regardless of aspect ratio. + Specifying only height will rescale the image to that number of pixels in height + while preserving the aspect ratio. + width: Optional value for the width of the saved images. Specifying both the width + and height will resize images to an exact size, regardless of aspect ratio. + Specifying only width will rescale the image to that number of pixels wide + while preserving the aspect ratio. + interpolation: Type of interpolation to use when resizing images. + threading: Offload image encoding and disk IO to background threads to improve performance. + + Returns: + Dictionary of the format { scene_num : [image_paths] }, where scene_num is the + number of the scene in scene_list (starting from 1), and image_paths is a list of + the paths to the newly saved/created images. + + Raises: + ValueError: Raised if any arguments are invalid or out of range (e.g. + if num_images is negative). + """ + + if not scene_list: + return {} + 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. + imwrite_param = ( + [get_cv2_imwrite_params()[image_extension], encoder_param] + if encoder_param is not None + else [] + ) + video.reset() + + if threading: + extractor = _ImageExtractor( + num_images, + frame_margin, + image_extension, + imwrite_param, + image_name_template, + scale, + height, + width, + interpolation, + ) + 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}]" + f" {output_dir if output_dir else ''} " + ) + progress_bar = None + if show_progress: + progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) + + filename_template = Template(image_name_template) + + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" + + 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 + if abs(aspect_ratio - 1.0) < 0.01: + aspect_ratio = None + + logger.debug("Writing images with template %s", filename_template.template) + for i, scene_timecodes in enumerate(timecode_list): + for j, image_timecode in enumerate(scene_timecodes): + video.seek(image_timecode) + frame_im = video.read() + if isinstance(frame_im, np.ndarray): + # TODO: Add extension to template. + # TODO: Allow NUM to be a valid suffix in addition to NUMBER. + file_path = "{}.{}".format( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (i + 1), + IMAGE_NUMBER=image_num_format % (j + 1), + FRAME_NUMBER=image_timecode.frame_num, + TIMESTAMP_MS=int(image_timecode.seconds * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), + image_extension, + ) + image_filenames[i].append(file_path) + # TODO: Combine this resize with the ones below. + if aspect_ratio is not None: + frame_im = cv2.resize( + frame_im, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value + ) + frame_height = frame_im.shape[0] + frame_width = frame_im.shape[1] + + # Figure out what kind of resizing needs to be done + if height or width: + if height and not width: + factor = height / float(frame_height) + width = int(factor * frame_width) + 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 + ) + elif scale: + frame_im = cv2.resize( + frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value + ) + path = Path(get_and_create_path(file_path, output_dir)) + (is_ok, encoded) = cv2.imencode(f".{image_extension}", frame_im, imwrite_param) + if is_ok: + encoded.tofile(path) + else: + logger.error(f"Failed to encode image for {file_path}") + # + else: + completed = False + break + if progress_bar is not None: + progress_bar.update(1) + + if progress_bar is not None: + progress_bar.close() + + if not completed: + logger.error("Could not generate all output images.") + + return image_filenames diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py new file mode 100644 index 00000000..c3a0b4cf --- /dev/null +++ b/scenedetect/output/video.py @@ -0,0 +1,389 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +# This software may also invoke mkvmerge or FFmpeg, if available. +# FFmpeg is a trademark of Fabrice Bellard. +# mkvmerge is Copyright (C) 2005-2016, Matroska. +# Certain distributions of PySceneDetect may include the above software; +# see the included LICENSE-FFMPEG and LICENSE-MKVMERGE files. +# +"""The ``scenedetect.output.video`` module contains functions to split existing videos into clips +using ffmpeg or mkvmerge. + +These programs can be obtained from following URLs (note that mkvmerge is a part mkvtoolnix): + + * FFmpeg: [ https://ffmpeg.org/download.html ] + * mkvmerge: [ https://mkvtoolnix.download/downloads.html ] + +If you are a Linux user, you can likely obtain the above programs from your package manager. + +Once installed, ensure the program can be accessed system-wide by calling the `mkvmerge` or `ffmpeg` +command from a terminal/command prompt. PySceneDetect will automatically use whichever program is +available on the computer, depending on the specified command-line options. +""" + +import logging +import math +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, + get_mkvmerge_path, + invoke_command, + tqdm, +) + +logger = logging.getLogger("pyscenedetect") + +_COMMAND_TOO_LONG_STRING = """ +Cannot split video due to too many scenes (resulting command +is too large to process). To work around this issue, you can +split the video manually by exporting a list of cuts with the +`list-scenes` command. +See https://github.com/Breakthrough/PySceneDetect/issues/164 +for details. Sorry about that! +""" + +# TODO: Resolve this on first use (e.g., functools.cache on the getter) rather than at import +# time, so that importing this module doesn't spawn an ffmpeg subprocess. +_FFMPEG_PATH: str | None = get_ffmpeg_path() +"""Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" + +_DEFAULT_FFMPEG_ARGS = ( + "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac" +) +"""Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" + +## +## Command Availability Checking Functions +## + + +def is_mkvmerge_available() -> bool: + """Is mkvmerge Available: Gracefully checks if mkvmerge command is available. + + Returns: + True if `mkvmerge` can be invoked, False otherwise. + """ + return get_mkvmerge_path() is not None + + +def is_ffmpeg_available() -> bool: + """Is ffmpeg Available: Gracefully checks if ffmpeg command is available. + + Returns: + True if `ffmpeg` can be invoked, False otherwise. + """ + return _FFMPEG_PATH is not None + + +## +## Output Naming +## + + +@dataclass +class VideoMetadata: + """Information about the video being split.""" + + name: str + """Expected name of the video. May differ from `path`.""" + path: Path + """Path to the input file.""" + total_scenes: int + """Total number of scenes that will be written.""" + + +@dataclass +class SceneMetadata: + """Information about the scene being extracted.""" + + index: int + """0-based index of this scene.""" + start: FrameTimecode + """First frame.""" + end: FrameTimecode + """Last frame.""" + + +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`, + `$START_PTS`, `$END_PTS` (presentation timestamp in milliseconds, accurate for VFR video) + """ + MIN_DIGITS = 3 + + 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 + + +## +## Split Video Functions +## + + +def split_video_mkvmerge( + input_video_path: str, + 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: + """Split `input_video_path` using `mkvmerge` based on the scenes in `scene_list`. + + Arguments: + input_video_path: Path to the video to be split. + scene_list : List of scenes as pairs of FrameTimecodes denoting the start/end times. + output_dir: Directory to output videos. If not set, output will be in working directory. + output_file_template: Template to use for generating output files. Note that mkvmerge always + adds the suffix "-$SCENE_NUMBER" to the output paths. Only the $VIDEO_NAME variable + is supported by this function. + video_name: Name of the video to be substituted in output_file_template for + $VIDEO_NAME. If not specified, will be obtained from the filename. + show_output: If False, adds the --quiet flag when invoking `mkvmerge`. + suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. + Returns: + Return code of invoking mkvmerge (0 on success). If scene_list is empty, will + still return 0, but no commands will be invoked. + """ + # Handle backwards compatibility with v0.5 API. + if isinstance(input_video_path, list): + logger.error("Using a list of paths is deprecated. Pass a single path instead.") + if len(input_video_path) > 1: + raise ValueError("Concatenating multiple input videos is not supported.") + input_video_path = input_video_path[0] + if suppress_output is not None: + logger.error("suppress_output is deprecated, use show_output instead.") + show_output = not suppress_output + + if not scene_list: + return 0 + + if video_name is None: + video_name = Path(input_video_path).stem + + # mkvmerge doesn't support adding scene metadata to filenames. It always adds the scene + # number prefixed with a dash to the filenames. + template = Template(output_file_template) + output_path = template.safe_substitute(VIDEO_NAME=video_name) + if output_dir: + output_path = Path(output_dir) / output_path + output_path = Path(output_path) + logger.info(f"Splitting video with mkvmerge, path template: {output_path}") + # If there is only one scene, mkvmerge omits the suffix for the output. To make the filenames + # consistent with the output when there are multiple scenes present, we append "-001". + if len(scene_list) == 1: + output_path = output_path.with_stem(output_path.stem + "-001") + output_path.parent.mkdir(parents=True, exist_ok=True) + + call_list = ["mkvmerge"] + if not show_output: + call_list.append("--quiet") + call_list += [ + "-o", + str(output_path), + "--split", + "parts:{}".format( + ",".join( + [ + f"{start_time.get_timecode()}-{end_time.get_timecode()}" + for start_time, end_time in scene_list + ] + ) + ), + input_video_path, + ] + total_frames = scene_list[-1][1].frame_num - scene_list[0][0].frame_num + processing_start_time = time.time() + ret_val = 0 + try: + # TODO: Capture stdout/stderr and show that if the command fails. + ret_val = invoke_command(call_list) + if show_output: + logger.info( + "Average processing speed %.2f frames/sec.", + float(total_frames) / (time.time() - processing_start_time), + ) + except CommandTooLong: + logger.error(_COMMAND_TOO_LONG_STRING) + except OSError: + logger.error( + "mkvmerge could not be found on the system." + " Please install mkvmerge to enable video output support." + ) + if ret_val != 0: + logger.error("Error splitting video (mkvmerge returned %d).", ret_val) + return ret_val + + +def split_video_ffmpeg( + input_video_path: str, + scene_list: Sequence[TimecodePair], + output_dir: str | Path | None = None, + output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", + video_name: str | None = None, + arg_override: str = _DEFAULT_FFMPEG_ARGS, + show_progress: bool = False, + show_output: bool = False, + suppress_output=None, + hide_progress=None, + formatter: PathFormatter | None = None, +) -> int: + """Split `input_video_path` using `ffmpeg` based on the scenes in `scene_list`. + + Arguments: + input_video_path: Path to the video to be split. + scene_list: List of scenes (pairs of FrameTimecodes) denoting the start/end of each scene. + output_dir: Directory to output videos. If not set, output will be in working directory. + output_file_template: Template to use for generating output filenames. + The following variables will be replaced in the template for each scene: + $VIDEO_NAME, $SCENE_NUMBER, $START_TIME, $END_TIME, $START_FRAME, $END_FRAME + video_name: Name of the video to be substituted in output_file_template. If not + passed will be calculated from input_video_path automatically. + arg_override: Allows overriding the arguments passed to ffmpeg for encoding. + show_progress: If True, will show progress bar provided by tqdm (if installed). + show_output: If True, will show output from ffmpeg for first split. + suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. + hide_progress: [DEPRECATED] DO NOT USE. For backwards compatibility only. + formatter: Custom formatter callback. Overrides `output_file_template`. + + Returns: + Return code of invoking ffmpeg (0 on success). If scene_list is empty, will + still return 0, but no commands will be invoked. + """ + # Handle backwards compatibility with v0.5 API. + if isinstance(input_video_path, list): + logger.error("Using a list of paths is deprecated. Pass a single path instead.") + if len(input_video_path) > 1: + raise ValueError("Concatenating multiple input videos is not supported.") + input_video_path = input_video_path[0] + if suppress_output is not None: + logger.error("suppress_output is deprecated, use show_output instead.") + show_output = not suppress_output + if hide_progress is not None: + logger.error("hide_progress is deprecated, use show_progress instead.") + show_progress = not hide_progress + + if not scene_list: + return 0 + + logger.info("Splitting video with ffmpeg, output path template:\n %s", output_file_template) + if output_dir: + logger.info("Output folder:\n %s", output_file_template) + + if video_name is None: + video_name = Path(input_video_path).stem + + arg_override = arg_override.replace('\\"', '"') + + ret_val = 0 + 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=Path(input_video_path), total_scenes=len(scene_list) + ) + + try: + progress_bar = None + total_frames = scene_list[-1][1].frame_num - scene_list[0][0].frame_num + if show_progress: + progress_bar = tqdm(total=total_frames, unit="frame", miniters=1, dynamic_ncols=True) + processing_start_time = time.time() + 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(video_metadata, scene_metadata)) + if output_dir: + output_path = Path(output_dir) / output_path + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Gracefully handle case where FFMPEG_PATH might be unset. + call_list = [_FFMPEG_PATH if _FFMPEG_PATH is not None else "ffmpeg"] + if not show_output: + call_list += ["-v", "quiet"] + elif i > 0: + # Only show ffmpeg output for the first call, which will display any + # errors if it fails, and then break the loop. We only show error messages + # for the remaining calls. + call_list += ["-v", "error"] + call_list += [ + "-nostdin", + "-y", + "-ss", + str(start_time.seconds), + "-i", + input_video_path, + "-t", + str(duration.seconds), + ] + call_list += ffmpeg_args + call_list += ["-sn"] + call_list += [str(output_path)] + ret_val = invoke_command(call_list) + if show_output and i == 0 and len(scene_list) > 1: + logger.info( + "Output from ffmpeg for Scene 1 shown above, splitting remaining scenes..." + ) + if ret_val != 0: + # TODO: Capture stdout/stderr and display it on any failed calls. + logger.error("Error splitting video (ffmpeg returned %d).", ret_val) + break + if progress_bar: + progress_bar.update(duration.frame_num) + + if progress_bar: + progress_bar.close() + if show_output: + logger.info( + "Average processing speed %.2f frames/sec.", + float(total_frames) / (time.time() - processing_start_time), + ) + + except CommandTooLong: + logger.error(_COMMAND_TOO_LONG_STRING) + except OSError: + logger.error( + "ffmpeg could not be found on the system." + " Please install ffmpeg to enable video output support." + ) + return ret_val diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 65aa7f80..9a783ea2 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -5,17 +5,17 @@ # [ 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. # """``scenedetect.platform`` Module -This moduke contains all platform/library specific compatibility fixes, as well as some utility +This module contains all platform/library specific compatibility fixes, as well as some utility 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 -from typing import AnyStr, Dict, List, Optional, Union 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() -> Dict[str, 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() -> Dict[str, Union[int, None]]: current system library (e.g. {'jpg': None}). """ - def _get_cv2_param(param_name: str) -> Union[int, None]: + def _get_cv2_param(param_name: str) -> int | None: if param_name.startswith("CV_"): param_name = param_name[3:] try: @@ -108,22 +125,19 @@ def _get_cv2_param(param_name: str) -> Union[int, None]: ## -def get_file_name(file_path: AnyStr, include_extension=True) -> 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: AnyStr, output_directory: Optional[AnyStr] = None) -> 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. @@ -141,10 +155,11 @@ def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = 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) @@ -157,7 +172,7 @@ def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = def init_logger( - log_level: int = logging.INFO, show_stdout: bool = False, log_file: 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 @@ -202,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: 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. @@ -231,7 +246,7 @@ def invoke_command(args: List[str]) -> int: raise -def get_ffmpeg_path() -> 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. """ @@ -261,7 +276,7 @@ def get_ffmpeg_path() -> Optional[str]: return None -def get_ffmpeg_version() -> 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: @@ -275,7 +290,17 @@ def get_ffmpeg_version() -> Optional[str]: return output.splitlines()[0] -def get_mkvmerge_version() -> 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: @@ -290,58 +315,103 @@ def get_mkvmerge_version() -> 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 = "{:<12} {}" line_separator = "-" * 60 not_found_str = "Not Installed" out_lines = [] - # System (Python, OS) - out_lines += ["System Info", line_separator] - out_lines += [ - output_template.format(name, version) - for name, version in ( - ("OS", "%s" % platform.platform()), - ("Python", "%d.%d.%d" % sys.version_info[0:3]), - ) - ] + 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", - "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) - out_lines.append(output_template.format(module_name, module.__version__)) - 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 4352d1c5..fed33b97 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -5,223 +5,18 @@ # [ 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. # -"""``scenedetect.scene_detector`` Module +"""DEPRECATED""" -This module contains the :class:`SceneDetector` interface, from which all scene detectors in -:mod:`scenedetect.detectors` module are derived from. +import warnings -The SceneDetector class represents the interface which detection algorithms are expected to provide -in order to be compatible with PySceneDetect. +warnings.warn( + "The `scene_detector` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) -.. warning:: - - This API is still unstable, and changes and design improvements are planned for the v1.0 - release. Instead of just timecodes, detection algorithms will also provide a specific type of - event (in, out, cut, etc...). -""" - -import typing as ty -from enum import Enum - -import numpy - -from scenedetect.stats_manager import StatsManager - - -class SceneDetector: - """Base class to inherit from when implementing a scene detection algorithm. - - This API is not yet stable and subject to change. - - This represents a "dense" scene detector, which returns a list of frames where - the next scene/shot begins in a video. - - Also see the implemented scene detectors in the scenedetect.detectors module - to get an idea of how a particular detector can be created. - """ - - # TODO(v0.7): Make this a proper abstract base class. - - stats_manager: ty.Optional[StatsManager] = None - """Optional :class:`StatsManager ` to - use for caching frame metrics to and from.""" - - # TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain. - def is_processing_required(self, frame_num: int) -> bool: - """[DEPRECATED] DO NOT USE - - Test if all calculations for a given frame are already done. - - Returns: - False if the SceneDetector has assigned _metric_keys, and the - stats_manager property is set to a valid StatsManager object containing - the required frame metrics/calculations for the given frame - thus, not - needing the frame to perform scene detection. - - True otherwise (i.e. the frame_img passed to process_frame is required - to be passed to process_frame for the given frame_num). - """ - metric_keys = self.get_metrics() - return not metric_keys or not ( - self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys) - ) - - def stats_manager_required(self) -> bool: - """Stats Manager Required: Prototype indicating if detector requires stats. - - Returns: - True if a StatsManager is required for the detector, False otherwise. - """ - return False - - def get_metrics(self) -> ty.List[str]: - """Get Metrics: Get a list of all metric names/keys used by the detector. - - Returns: - List of strings of frame metric key names that will be used by - the detector when a StatsManager is passed to process_frame. - """ - return [] - - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: - """Process the next frame. `frame_num` is assumed to be sequential. - - 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`. - - Returns: - 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. - - Returns: - List of frame numbers of cuts to be added to the cutting list. - """ - return [] - - def post_process(self, frame_num: int) -> ty.List[int]: - """Post Process: Performs any processing after the last frame has been read. - - Prototype method, no actual detection. - - Returns: - List of frame numbers of cuts to be added to the cutting list. - """ - return [] - - @property - def event_buffer_length(self) -> int: - """The amount of frames a given event can be buffered for, in time. Represents maximum - amount any event can be behind `frame_number` in the result of :meth:`process_frame`. - """ - return 0 - - -class SparseSceneDetector(SceneDetector): - """Base class to inherit from when implementing a sparse scene detection algorithm. - - This class will be removed in v1.0 and should not be used. - - Unlike dense detectors, sparse detectors detect "events" and return a *pair* of frames, - as opposed to just a single cut. - - An example of a SparseSceneDetector is the MotionDetector. - """ - - def process_frame( - self, frame_num: int, frame_img: numpy.ndarray - ) -> ty.List[ty.Tuple[int, int]]: - """Process Frame: Computes/stores metrics and detects any scene changes. - - Prototype method, no actual detection. - - Returns: - List of frame pairs representing individual scenes - to be added to the output scene list directly. - """ - return [] - - def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: - """Post Process: Performs any processing after the last frame has been read. - - Prototype method, no actual detection. - - Returns: - List of frame pairs representing individual scenes - to be added to the output scene list directly. - """ - return [] - - -class FlashFilter: - class Mode(Enum): - MERGE = 0 - """Merge consecutive cuts shorter than filter length.""" - SUPPRESS = 1 - """Suppress consecutive cuts until the filter length has passed.""" - - def __init__(self, mode: Mode, length: int): - self._mode = mode - self._filter_length = length # Number of frames to use for activating the filter. - self._last_above = None # Last frame above threshold. - self._merge_enabled = False # Used to disable merging until at least one cut was found. - self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filter. - - @property - def max_behind(self) -> int: - """Maximum number of frames a filtered cut can be behind the current frame.""" - return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length - - def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - if not self._filter_length > 0: - return [frame_num] if above_threshold else [] - if self._last_above is None: - self._last_above = frame_num - if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) - elif self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) - raise RuntimeError("Unhandled FlashFilter mode.") - - def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - min_length_met: bool = (frame_num - self._last_above) >= self._filter_length - if not (above_threshold and min_length_met): - return [] - # Both length and threshold requirements were satisfied. Emit the cut, and wait until both - # requirements are met again. - self._last_above = frame_num - return [frame_num] - - def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - min_length_met: bool = (frame_num - self._last_above) >= self._filter_length - # Ensure last frame is always advanced to the most recent one that was above the threshold. - if above_threshold: - self._last_above = frame_num - 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: - self._merge_triggered = False - return [self._last_above] - # Keep merging until enough frames pass below the threshold. - return [] - # Wait for next frame above the threshold. - if not above_threshold: - return [] - # If we met the minimum length requirement, no merging is necessary. - if min_length_met: - # Only allow the merge filter once the first cut is emitted. - self._merge_enabled = True - return [frame_num] - # Start merging cuts until the length requirement is met. - if self._merge_enabled: - self._merge_triggered = True - self._merge_start = frame_num - return [] +from scenedetect.detector import * # noqa: E402, F403 diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 945f85bd..5cb46189 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. # @@ -16,9 +16,6 @@ (:mod:`VideoStream `). Video decoding is done in a separate thread to improve performance. -This module also contains other helper functions (e.g. :func:`save_images`) which can be used to -process the resulting scene list. - =============================================================== Usage =============================================================== @@ -79,39 +76,34 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): analysis of the video. """ -import csv import logging -import math import queue import sys import threading -from enum import Enum -from typing import Callable, Dict, Iterable, List, Optional, TextIO, Tuple, Union +import typing as ty +import warnings import cv2 import numpy as np -from scenedetect._thirdparty.simpletable import ( - HTMLPage, - SimpleTable, - SimpleTableCell, - SimpleTableImage, - SimpleTableRow, +from scenedetect.common import ( + CropRegion, + CutList, + FrameTimecode, + Interpolation, + SceneList, + TimecodeLike, ) -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.platform import Template, get_and_create_path, get_cv2_imwrite_params, tqdm -from scenedetect.scene_detector import SceneDetector, SparseSceneDetector +from scenedetect.detector import SceneDetector + +# TODO(v0.8): Remove the import * below, for backwards compatibility with v0.6 only. +from scenedetect.output import * # noqa: F403 +from scenedetect.platform import tqdm from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream logger = logging.getLogger("pyscenedetect") -SceneList = List[Tuple[FrameTimecode, FrameTimecode]] -"""Type hint for a list of scenes in the form (start time, end time).""" - -CutList = List[FrameTimecode] -"""Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" - # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current # value doesn't seem to have caused any issues at least. @@ -128,22 +120,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): """Template to use for progress bar.""" -class Interpolation(Enum): - """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" - - NEAREST = cv2.INTER_NEAREST - """Nearest neighbor interpolation.""" - LINEAR = cv2.INTER_LINEAR - """Bilinear interpolation.""" - CUBIC = cv2.INTER_CUBIC - """Bicubic interpolation.""" - AREA = cv2.INTER_AREA - """Pixel area relation resampling. Provides moire'-free downscaling.""" - LANCZOS4 = cv2.INTER_LANCZOS4 - """Lanczos interpolation over 8x8 neighborhood.""" - - -def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> int: +def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> float: """Get the optimal default downscale factor based on a video's resolution (currently only the width in pixels is considered). @@ -157,17 +134,44 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI Returns: int: The default downscale factor to use to achieve at least the target effective_width. """ - assert not (frame_width < 1 or effective_width < 1) + assert frame_width > 0 and effective_width > 0 if frame_width < effective_width: return 1 - return frame_width // effective_width + 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: Union[int, FrameTimecode], - end_pos: Union[int, FrameTimecode], - base_timecode: Optional[FrameTimecode] = None, + 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. @@ -179,20 +183,15 @@ def get_scenes_from_cuts( Arguments: cut_list: List of FrameTimecode objects where scene cuts/breaks occur. - base_timecode: The base_timecode of which all FrameTimecodes in the cut_list are based on. num_frames: The number of frames, or FrameTimecode representing duration, of the video that was processed (used to generate last scene's end time). start_frame: The start frame or FrameTimecode of the cut list. Used to generate the first scene's start time. - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. Returns: List of tuples in the form (start_time, end_time), where both start_time and end_time are FrameTimecode objects representing the exact time/frame where each scene occupies based on the input cut_list. """ - # TODO(v0.7): Use the warnings module to turn this into a warning. - if base_timecode is not None: - logger.error("`base_timecode` argument is deprecated has no effect.") # Scene list, where scenes are tuples of (Start FrameTimecode, End FrameTimecode). scene_list = [] @@ -211,392 +210,6 @@ def get_scenes_from_cuts( return scene_list -def write_scene_list( - output_csv_file: TextIO, - scene_list: SceneList, - include_cut_list: bool = True, - cut_list: Optional[CutList] = None, -) -> None: - """Writes the given list of scenes to an output file handle in CSV format. - - Arguments: - output_csv_file: Handle to open file in write mode. - scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. - include_cut_list: Bool indicating if the first row should include the timecodes where - each scene starts. Should be set to False if RFC 4180 compliant CSV output is required. - cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames - in the video that need to be split to generate individual scenes). If not specified, - the cut list is generated using the start times of each scene following the first one. - """ - csv_writer = csv.writer(output_csv_file, lineterminator="\n") - # 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 - if cut_list - else [start.get_timecode() for start, _ in scene_list[1:]] - ) - csv_writer.writerow( - [ - "Scene Number", - "Start Frame", - "Start Timecode", - "Start Time (seconds)", - "End Frame", - "End Timecode", - "End Time (seconds)", - "Length (frames)", - "Length (timecode)", - "Length (seconds)", - ] - ) - for i, (start, end) in enumerate(scene_list): - duration = end - start - csv_writer.writerow( - [ - "%d" % (i + 1), - "%d" % (start.get_frames() + 1), - start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), - end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), - duration.get_timecode(), - "%.3f" % duration.get_seconds(), - ] - ) - - -def write_scene_list_html( - output_html_filename: str, - scene_list: SceneList, - cut_list: Optional[CutList] = None, - css: str = None, - css_class: str = "mytable", - image_filenames: Optional[Dict[int, List[str]]] = None, - image_width: Optional[int] = None, - image_height: Optional[int] = None, -): - """Writes the given list of scenes to an output file handle in html format. - - Arguments: - output_html_filename: filename of output html file - scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. - cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames - in the video that need to be split to generate individual scenes). If not passed, - the start times of each scene (besides the 0th scene) is used instead. - css: String containing all the css information for the resulting html page. - css_class: String containing the named css class - image_filenames: dict where key i contains a list with n elements (filenames of - the n saved images from that scene) - image_width: Optional desired width of images in table in pixels - image_height: Optional desired height of images in table in pixels - """ - logger.info("Exporting scenes to html:\n %s:", output_html_filename) - if not css: - css = """ - table.mytable { - font-family: times; - font-size:12px; - color:#000000; - border-width: 1px; - border-color: #eeeeee; - border-collapse: collapse; - background-color: #ffffff; - width=100%; - max-width:550px; - table-layout:fixed; - } - table.mytable th { - border-width: 1px; - padding: 8px; - border-style: solid; - border-color: #eeeeee; - background-color: #e6eed6; - color:#000000; - } - table.mytable td { - border-width: 1px; - padding: 8px; - border-style: solid; - border-color: #eeeeee; - } - #code { - display:inline; - font-family: courier; - color: #3d9400; - } - #string { - display:inline; - font-weight: bold; - } - """ - - # Output Timecode list - timecode_table = SimpleTable( - [ - ["Timecode List:"] - + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) - ], - css_class=css_class, - ) - - # Output list of scenes - header_row = [ - "Scene Number", - "Start Frame", - "Start Timecode", - "Start Time (seconds)", - "End Frame", - "End Timecode", - "End Time (seconds)", - "Length (frames)", - "Length (timecode)", - "Length (seconds)", - ] - for i, (start, end) in enumerate(scene_list): - duration = end - start - - row = SimpleTableRow( - [ - "%d" % (i + 1), - "%d" % (start.get_frames() + 1), - start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), - end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), - duration.get_timecode(), - "%.3f" % duration.get_seconds(), - ] - ) - - if image_filenames: - for image in image_filenames[i]: - row.add_cell( - SimpleTableCell(SimpleTableImage(image, width=image_width, height=image_height)) - ) - - if i == 0: - scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) - else: - scene_table.add_row(row=row) - - # Write html file - page = HTMLPage() - page.add_table(timecode_table) - page.add_table(scene_table) - page.css = css - page.save(output_html_filename) - - -# -# TODO(v1.0): Consider moving all post-processing functionality into a separate submodule. -def save_images( - scene_list: SceneList, - video: VideoStream, - num_images: int = 3, - frame_margin: int = 1, - image_extension: str = "jpg", - encoder_param: int = 95, - image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: Optional[str] = None, - show_progress: Optional[bool] = False, - scale: Optional[float] = None, - height: Optional[int] = None, - width: Optional[int] = None, - interpolation: Interpolation = Interpolation.CUBIC, - video_manager=None, -) -> Dict[int, List[str]]: - """Save a set number of images from each scene, given a list of scenes - and the associated video/frame source. - - Arguments: - scene_list: A list of scenes (pairs of FrameTimecode objects) returned - from calling a SceneManager's detect_scenes() method. - 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. - 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. - 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. - image_name_template: Template to use for naming image files. Can use the template variables - $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. - Should not include an extension. - output_dir: Directory to output the images into. If not set, the output - is created in the working directory. - show_progress: If True, shows a progress bar if tqdm is installed. - scale: Optional factor by which to rescale saved images. A scaling factor of 1 would - not result in rescaling. A value < 1 results in a smaller saved image, while a - value > 1 results in an image larger than the original. This value is ignored if - either the height or width values are specified. - height: Optional value for the height of the saved images. Specifying both the height - and width will resize images to an exact size, regardless of aspect ratio. - Specifying only height will rescale the image to that number of pixels in height - while preserving the aspect ratio. - width: Optional value for the width of the saved images. Specifying both the width - and height will resize images to an exact size, regardless of aspect ratio. - Specifying only width will rescale the image to that number of pixels wide - while preserving the aspect ratio. - interpolation: Type of interpolation to use when resizing images. - video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. - - Returns: - Dictionary of the format { scene_num : [image_paths] }, where scene_num is the - number of the scene in scene_list (starting from 1), and image_paths is a list of - the paths to the newly saved/created images. - - Raises: - ValueError: Raised if any arguments are invalid or out of range (e.g. - if num_images is negative). - """ - # TODO(v0.7): Add DeprecationWarning that `video_manager` will be removed in v0.8. - if video_manager is not None: - logger.error("`video_manager` argument is deprecated, use `video` instead.") - video = video_manager - - if not scene_list: - return {} - if num_images <= 0 or frame_margin < 0: - raise ValueError() - - # 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. - imwrite_param = ( - [get_cv2_imwrite_params()[image_extension], encoder_param] - if encoder_param is not None - else [] - ) - - video.reset() - - # Setup flags and init progress bar if available. - completed = True - logger.info(f"Saving {num_images} images per scene to {output_dir}, format {image_extension}") - progress_bar = None - if show_progress: - progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) - - filename_template = Template(image_name_template) - - scene_num_format = "%0" - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" - image_num_format = "%0" - image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - - framerate = scene_list[0][0].framerate - - # 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.get_frames(), - start.get_frames() - + max( - 1, # guard against zero length scenes - end.get_frames() - start.get_frames(), - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ] - ) - ] - - image_filenames = {i: [] for i in range(len(timecode_list))} - aspect_ratio = video.aspect_ratio - if abs(aspect_ratio - 1.0) < 0.01: - aspect_ratio = None - - logger.debug("Writing images with template %s", filename_template.template) - for i, scene_timecodes in enumerate(timecode_list): - for j, image_timecode in enumerate(scene_timecodes): - video.seek(image_timecode) - frame_im = video.read() - if frame_im is not None: - # TODO: Add extension to template. - # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = "%s.%s" % ( - filename_template.safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=scene_num_format % (i + 1), - IMAGE_NUMBER=image_num_format % (j + 1), - FRAME_NUMBER=image_timecode.get_frames(), - TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), - TIMECODE=image_timecode.get_timecode().replace(":", ";"), - ), - image_extension, - ) - image_filenames[i].append(file_path) - # TODO: Combine this resize with the ones below. - if aspect_ratio is not None: - frame_im = cv2.resize( - frame_im, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value - ) - frame_height = frame_im.shape[0] - frame_width = frame_im.shape[1] - - # Figure out what kind of resizing needs to be done - if height or width: - if height and not width: - factor = height / float(frame_height) - width = int(factor * frame_width) - if width and not height: - factor = width / float(frame_width) - height = int(factor * frame_height) - assert height > 0 and width > 0 - frame_im = cv2.resize( - frame_im, (width, height), interpolation=interpolation.value - ) - elif scale: - frame_im = cv2.resize( - frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value - ) - - cv2.imwrite(get_and_create_path(file_path, output_dir), frame_im, imwrite_param) - else: - completed = False - break - if progress_bar is not None: - progress_bar.update(1) - - if progress_bar is not None: - progress_bar.close() - - if not completed: - logger.error("Could not generate all output images.") - - return image_filenames - - ## ## SceneManager Class Implementation ## @@ -610,47 +223,44 @@ class SceneManager: def __init__( self, - stats_manager: 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 = [] - self._event_list = [] - self._detector_list: List[SceneDetector] = [] - self._sparse_detector_list = [] + 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: 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: Tuple[int, int] = None + self._frame_size: tuple[int, int] | None = None self._frame_size_errors: int = 0 - self._base_timecode: 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 = [] + self._frame_buffer: list[tuple[FrameTimecode, np.ndarray]] = [] self._frame_buffer_size = 0 + self._crop = None @property def interpolation(self) -> Interpolation: @@ -662,10 +272,39 @@ def interpolation(self, value: Interpolation): self._interpolation = value @property - def stats_manager(self) -> 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) -> 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, + (0, 0, 99, 99) covers the entire frame.""" + if self._crop is None: + return None + (x0, y0, x1, y1) = self._crop + return (x0, y0, x1 - 1, y1 - 1) + + @crop.setter + def crop(self, value: CropRegion): + """Raises: + ValueError: All coordinates must be >= 0. + """ + if value is None: + self._crop = None + return + if not (len(value) == 4 and all(isinstance(v, int) for v in value)): + raise TypeError("crop region must be tuple of 4 ints") + # Verify that the provided crop results in a non-empty portion of the frame. + if any(coordinate < 0 for coordinate in value): + raise ValueError("crop coordinates must be >= 0") + (x0, y0, x1, y1) = value + # Internally we store the value in the form used to de-reference the image, which must be + # one-past the end. + self._crop = (min(x0, x1), min(y0, y1), max(x0, x1) + 1, max(y0, y1) + 1) + @property def downscale(self) -> int: """Factor to downscale each frame by. Will always be >= 1, where 1 @@ -703,20 +342,12 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ - if self._stats_manager is None and detector.stats_manager_required(): - # Make sure the lists are empty so that the detectors don't get - # out of sync (require an explicit statsmanager instead) - assert not self._detector_list and not self._sparse_detector_list - self._stats_manager = StatsManager() detector.stats_manager = self._stats_manager if self._stats_manager is not None: self._stats_manager.register_metrics(detector.get_metrics()) - if not issubclass(type(detector), SparseSceneDetector): - self._detector_list.append(detector) - else: - self._sparse_detector_list.append(detector) + self._detector_list.append(detector) self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) @@ -733,7 +364,6 @@ def clear(self) -> None: cached frame metrics that were computed and saved in the previous call to detect_scenes. """ self._cutting_list.clear() - self._event_list.clear() self._last_pos = None self._start_pos = None self._frame_size = None @@ -742,15 +372,11 @@ def clear(self) -> None: def clear_detectors(self) -> None: """Remove all scene detectors added to the SceneManager via add_detector().""" self._detector_list.clear() - self._sparse_detector_list.clear() - def get_scene_list( - self, base_timecode: Optional[FrameTimecode] = None, start_in_scene: bool = False - ) -> SceneList: + def get_scene_list(self, start_in_scene: bool = False) -> SceneList: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility. start_in_scene: Assume the video begins in a scene. This means that when detecting fast cuts with `ContentDetector`, if no cuts are found, the resulting scene list will contain a single scene spanning the entire video (instead of no scenes). @@ -762,10 +388,7 @@ def get_scene_list( end_time are FrameTimecode objects representing the exact time/frame where each detected scene in the video begins and ends. """ - # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. - if base_timecode is not None: - logger.error("`base_timecode` argument is deprecated and has no effect.") - 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( @@ -775,62 +398,46 @@ def get_scene_list( # unless start_in_scene is True. if not cut_list and not start_in_scene: scene_list = [] - return sorted(self._get_event_list() + scene_list) + return sorted(scene_list) - def _get_cutting_list(self) -> List[int]: + 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 [] - assert self._base_timecode is not None # Ensure all cuts are unique by using a set to remove all duplicates. - return [self._base_timecode + cut for cut in sorted(set(self._cutting_list))] - - def _get_event_list(self) -> SceneList: - if not self._event_list: - return [] - assert self._base_timecode is not None - return [ - (self._base_timecode + start, self._base_timecode + end) - for start, end in self._event_list - ] + return [cut for cut in sorted(set(self._cutting_list))] def _process_frame( self, - frame_num: int, + position: FrameTimecode, frame_im: np.ndarray, - callback: Optional[Callable[[np.ndarray, int], 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.""" new_cuts = False - # TODO(#283): This breaks with AdaptiveDetector as cuts differ from the frame number - # being processed. Allow detectors to specify the max frame lookahead they require - # (i.e. any event will never be more than N frames behind the current one). - self._frame_buffer.append(frame_im) + # TODO(https://scenedetect.com/issues/283): This breaks with AdaptiveDetector as cuts differ + # from the frame number being processed. Allow detectors to specify the max frame lookahead + # they require (i.e. any event will never be more than N frames behind the current one). + self._frame_buffer.append((position, frame_im)) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] for detector in self._detector_list: - cuts = detector.process_frame(frame_num, frame_im) + cuts = detector.process_frame(position, frame_im) self._cutting_list += cuts - new_cuts = True if cuts else False - if callback: - for cut_frame_num in cuts: - buffer_index = cut_frame_num - (frame_num + 1) - callback(self._frame_buffer[buffer_index], cut_frame_num) - for detector in self._sparse_detector_list: - events = detector.process_frame(frame_num, frame_im) - self._event_list += events + new_cuts = bool(cuts) if callback: - for event_start, _ in events: - buffer_index = event_start - (frame_num + 1) - callback(self._frame_buffer[buffer_index], event_start) + for cut in cuts: + for position, frame in self._frame_buffer: + if cut == position: + callback(frame, position) return new_cuts - def _post_process(self, frame_num: int) -> None: + def _post_process(self, timecode: FrameTimecode) -> None: """Add remaining cuts to the cutting list, after processing the last frame.""" for detector in self._detector_list: - self._cutting_list += detector.post_process(frame_num) + self._cutting_list += detector.post_process(timecode) def stop(self) -> None: """Stop the current :meth:`detect_scenes` call, if any. Thread-safe.""" @@ -838,13 +445,13 @@ def stop(self) -> None: def detect_scenes( self, - video: VideoStream = None, - duration: Optional[FrameTimecode] = None, - end_time: 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: Optional[Callable[[np.ndarray, int], None]] = None, - frame_source: 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 @@ -870,14 +477,20 @@ def detect_scenes( complete processing the video frame source. callback: If set, called after each scene/event detected. frame_source: [DEPRECATED] DO NOT USE. For compatibility with previous version. + :meta private: Returns: int: Number of frames read and processed from the frame source. Raises: 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.", + DeprecationWarning, + stacklevel=2, + ) video = frame_source # TODO(v0.8): Remove default value for `video` after `frame_source` is removed. if video is None: @@ -892,6 +505,35 @@ def detect_scenes( if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: raise ValueError("end_time must be greater than or equal to 0!") + 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]}" + ) + 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)) + frame_width, frame_height = video.frame_size + if min_x >= frame_width or min_y >= frame_height: + raise ValueError("crop starts outside video boundary") + if max_x >= frame_width or max_y >= frame_height: + logger.warning("Warning: crop ends outside of video boundary.") + effective_frame_size = ( + 1 + min(max_x, frame_width) - min_x, + 1 + min(max_y, frame_height) - min_y, + ) + # Calculate downscale factor and log effective resolution. + if self.auto_downscale: + downscale_factor = compute_downscale_factor(max(effective_frame_size)) + else: + downscale_factor = self.downscale + logger.debug( + "Processing resolution: %d x %d, downscale: %1.1f", + int(effective_frame_size[0] / downscale_factor), + int(effective_frame_size[1] / downscale_factor), + downscale_factor, + ) + self._base_timecode = video.base_timecode # TODO: Figure out a better solution for communicating framerate to StatsManager. @@ -909,20 +551,7 @@ def detect_scenes( if end_time is not None and end_time < video.duration: total_frames = end_time - start_frame_num else: - total_frames = video.duration.get_frames() - start_frame_num - - # Calculate the desired downscale factor and log the effective resolution. - if self.auto_downscale: - downscale_factor = compute_downscale_factor(frame_width=video.frame_size[0]) - else: - downscale_factor = self.downscale - if downscale_factor > 1: - logger.info( - "Downscale factor set to %d, effective resolution: %d x %d", - downscale_factor, - video.frame_size[0] // downscale_factor, - video.frame_size[1] // downscale_factor, - ) + total_frames = video.duration.frame_num - start_frame_num progress_bar = None if show_progress: @@ -944,43 +573,53 @@ def detect_scenes( frame_im = 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_num, frame_im, callback) + 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 + ) + progress_bar.update(1 + frame_skip) + finally: if progress_bar is not None: - if new_cuts: - progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False - ) - 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.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.frame_num) + self._post_process(video.position) + return video.frame_number - start_frame_num def _decode_thread( self, video: VideoStream, frame_skip: int, - downscale_factor: int, + downscale_factor: float, end_time: FrameTimecode, out_queue: queue.Queue, ): @@ -990,49 +629,46 @@ def _decode_thread( # We don't do any kind of locking here since the worst-case of this being wrong # is that we do some extra work, and this function should never mutate any data # (all of which should be modified under the GIL). - # TODO(v1.0): This optimization should be removed as it is an uncommon use case and - # greatly increases the complexity of detection algorithms using it. - if self._is_processing_required(video.position.frame_num): - frame_im = video.read() - if frame_im is False: - break - # 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]) - if self._frame_size is None: - self._frame_size = decoded_size - if video.frame_size != decoded_size: - logger.warn( - f"WARNING: Decoded frame size ({decoded_size}) does not match " - f" video resolution {video.frame_size}, possible corrupt input." - ) - elif self._frame_size != decoded_size: - 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"cannot be processed: decoded size = {decoded_size}, " - f"expected = {self._frame_size}. Video may be corrupt." - ) - if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: - logger.warn( - "WARNING: Too many errors emitted, skipping future messages." - ) - # Skip processing frames that have an incorrect size. - continue - - if downscale_factor > 1: - frame_im = cv2.resize( - frame_im, - ( - round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor), - ), - interpolation=self._interpolation.value, + 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]) + if self._frame_size is None: + self._frame_size = decoded_size + if video.frame_size != decoded_size: + logger.warn( + f"WARNING: Decoded frame size ({decoded_size}) does not match " + f" video resolution {video.frame_size}, possible corrupt input." + ) + elif self._frame_size != decoded_size: + self._frame_size_errors += 1 + if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: + logger.error( + 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." ) - else: - if video.read(decode=False) is False: - break + if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: + logger.warn("WARNING: Too many errors emitted, skipping future messages.") + # Skip processing frames that have an incorrect size. + continue + + if self._crop: + (x0, y0, x1, y1) = self._crop + frame_im = frame_im[y0:y1, x0:x1] + + if downscale_factor > 1.0: + frame_im = cv2.resize( + frame_im, + ( + max(1, round(frame_im.shape[1] / downscale_factor)), + max(1, round(frame_im.shape[0] / downscale_factor)), + ), + interpolation=self._interpolation.value, + ) # Set the start position now that we decoded at least the first frame. if self._start_pos is None: @@ -1072,7 +708,6 @@ def _decode_thread( def get_cut_list( self, - base_timecode: Optional[FrameTimecode] = None, show_warning: bool = True, ) -> CutList: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. @@ -1083,10 +718,7 @@ def get_cut_list( the scene list, noting that each scene is contiguous starting from the first frame and ending at the last frame detected. - If only sparse detectors are used (e.g. MotionDetector), this will always be empty. - Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. show_warning: If set to False, suppresses the error from being warned. In v0.7, this will have no effect and the error will become a Python warning. @@ -1095,36 +727,11 @@ def get_cut_list( was detected in the input video, which can also be passed to external tools for automated splitting of the input into individual scenes. - :meta private: """ - # TODO(v0.7): Use the warnings module to turn this into a warning. if show_warning: - logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") + warnings.warn( + "get_cut_list() is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) return self._get_cutting_list() - - def get_event_list(self, base_timecode: Optional[FrameTimecode] = None) -> SceneList: - """[DEPRECATED] DO NOT USE. - - Get a list of start/end timecodes of sparse detection events. - - Unlike get_scene_list, the event list returns a list of FrameTimecodes representing - the point in the input video where a new scene was detected only by sparse detectors, - otherwise it is the same. - - Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. - - Returns: - List of pairs of FrameTimecode objects denoting the detected scenes. - - :meta private: - """ - # TODO(v0.7): Use the warnings module to turn this into a warning. - logger.error("`get_event_list()` is deprecated and will be removed in a future release.") - return self._get_event_list() - - def _is_processing_required(self, frame_num: int) -> bool: - """True if frame metrics not in StatsManager, False otherwise.""" - if self.stats_manager is None: - return True - return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 320f726e..dc415f3c 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -5,14 +5,14 @@ # [ 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. # """``scenedetect.stats_manager`` Module This module contains the :class:`StatsManager` class, which provides a key-value store for each -:class:`SceneDetector ` to write the metrics calculated +:class:`SceneDetector ` to write the metrics calculated for each frame. The :class:`StatsManager` must be registered to a :class:`SceneManager ` upon construction. @@ -22,15 +22,14 @@ """ import csv +import os import os.path import typing as ty from logging import getLogger from pathlib import Path -# TODO: Replace below imports with `ty.` prefix. -from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union - -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode +from scenedetect.platform import StrPath logger = getLogger("pyscenedetect") @@ -97,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: Dict[FrameTimecode, Dict[str, float]] = dict() - self._metric_keys: 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: Optional[FrameTimecode] = ( + self._base_timecode: int | FrameTimecode | None = ( base_timecode # Used for timing calculations. ) @@ -117,45 +117,40 @@ def __init__(self, base_timecode: FrameTimecode = None): def metric_keys(self) -> ty.Iterable[str]: return self._metric_keys - def register_metrics(self, metric_keys: Iterable[str]) -> None: + def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: """Register a list of metric keys that will be used by the detector.""" self._metric_keys = self._metric_keys.union(set(metric_keys)) - # TODO(v1.0): Change frame_number to a FrameTimecode now that it is just a hash and will - # be required for VFR support. This API is also really difficult to use, this type should just - # function like a dictionary. - def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any]: - """Return the requested statistics/metrics for a given frame. - - Arguments: - frame_number (int): Frame number to retrieve metrics for. - metric_keys (List[str]): A list of metric keys to look up. + # 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: int | FrameTimecode, metric_keys: ty.Iterable[str] + ) -> list[ty.Any]: + """Return the requested statistics/metrics for a given timecode. Returns: - A list containing the requested frame metrics for the given frame number - in the same order as the input list of metric keys. If a metric could - not be found, None is returned for that particular metric. + A list containing the requested frame metrics for the given frame number, ordered as + they are in `metric_keys`. """ - return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] + return [self._get_metric(timecode, metric_key) for metric_key in metric_keys] - def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, 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: - frame_number: Frame number to retrieve metrics for. - metric_kv_dict: A dict mapping metric keys to the - respective integer/floating-point metric values to set. + timecode: Timecode to set metrics for. + metric_kv_dict: Key value mapping of metrics to their values for `timecode`. """ for metric_key in metric_kv_dict: - self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) + self._set_metric(timecode, metric_key, metric_kv_dict[metric_key]) - def metrics_exist(self, frame_number: int, metric_keys: 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: bool: True if the given metric keys exist for the frame, False otherwise. """ - return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys]) + return all([self._metric_exists(timecode, metric_key) for metric_key in metric_keys]) def is_save_required(self) -> bool: """Is Save Required: Checks if the stats have been updated since loading. @@ -168,49 +163,47 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: Union[str, bytes, Path, TextIO], - base_timecode: Optional[FrameTimecode] = None, + csv_file: StrPath | ty.TextIO, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. Arguments: csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str. - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility. force_save: If True, writes metrics out even if an update is not required. Raises: OSError: If `path` cannot be opened or a write failure occurs. """ - # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. - if base_timecode is not None: - logger.error("base_timecode is deprecated and has no effect.") - if not (force_save or self.is_save_required()): logger.info("No metrics to write.") return # 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_timecode = self._base_timecode + frame_key + # `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_timecode.get_frames() + 1, frame_timecode.get_timecode()] + [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: List[str]) -> bool: + def valid_header(row: list[str]) -> bool: """Check that the given CSV row is a valid header for a statsfile. Arguments: @@ -221,13 +214,11 @@ def valid_header(row: List[str]) -> bool: """ if not row or not len(row) >= 2: return False - if row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE: - return False - return True + return not (row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE) # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. - def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> 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 @@ -251,7 +242,7 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: # 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) @@ -296,7 +287,7 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: 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)) @@ -306,18 +297,18 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]: - if self._metric_exists(frame_number, metric_key): - return self._frame_metrics[frame_number][metric_key] + 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, frame_number: int, metric_key: str, metric_value: Any) -> None: + def _set_metric( + self, timecode: int | FrameTimecode, metric_key: str, metric_value: ty.Any + ) -> None: self._metrics_updated = True - if frame_number not in self._frame_metrics: - self._frame_metrics[frame_number] = dict() - self._frame_metrics[frame_number][metric_key] = metric_value + if timecode not in self._frame_metrics: + self._frame_metrics[timecode] = dict() + self._frame_metrics[timecode][metric_key] = metric_value - def _metric_exists(self, frame_number: int, metric_key: str) -> bool: - return ( - frame_number in self._frame_metrics and metric_key in self._frame_metrics[frame_number] - ) + 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_manager.py b/scenedetect/video_manager.py deleted file mode 100644 index ab09c8a5..00000000 --- a/scenedetect/video_manager.py +++ /dev/null @@ -1,812 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""``scenedetect.video_manager`` Module - -[DEPRECATED] DO NOT USE. Use `open_video` from `scenedetect.backends` or create a -VideoStreamCv2 object (`scenedetect.backends.opencv`) instead. - -This module exists for *some* backwards compatibility with v0.5, and will be removed -in a future release. -""" - -import math -import os -from logging import getLogger -from typing import Iterable, List, Optional, Tuple, Union - -import cv2 -import numpy as np - -from scenedetect.backends.opencv import _get_aspect_ratio -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -from scenedetect.platform import get_file_name -from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream - -## -## VideoManager Exceptions -## - - -class VideoParameterMismatch(Exception): - """VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some - of the video parameters (frame height, frame width, and framerate/FPS) do not match.""" - - def __init__( - self, file_list=None, message="OpenCV VideoCapture object parameters do not match." - ): - # type: (Iterable[Tuple[int, float, float, str, str]], str) -> None - # Pass message string to base Exception class. - super(VideoParameterMismatch, self).__init__(message) - # list of (param_mismatch_type: int, parameter value, expected value, - # filename: str, filepath: str) - # where param_mismatch_type is an OpenCV CAP_PROP (e.g. CAP_PROP_FPS). - self.file_list = file_list - - -class VideoDecodingInProgress(RuntimeError): - """VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that - must be called *before* start() has been called.""" - - -class InvalidDownscaleFactor(ValueError): - """InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, - i.e. the supplied downscale factor was not a positive integer greater than zero.""" - - -## -## VideoManager Helper Functions -## - - -def get_video_name(video_file: str) -> Tuple[str, str]: - """Get the video file/device name. - - Returns: - Tuple of the form [name, video_file]. - """ - if isinstance(video_file, int): - return ("Device %d" % video_file, video_file) - return (os.path.split(video_file)[1], video_file) - - -def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int: - """Get Number of Frames: Returns total number of frames in the cap_list. - - Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. - """ - return sum([math.trunc(cap.get(cv2.CAP_PROP_FRAME_COUNT)) for cap in cap_list]) - - -def open_captures( - video_files: Iterable[str], - framerate: Optional[float] = None, - validate_parameters: bool = True, -) -> Tuple[List[cv2.VideoCapture], float, Tuple[int, int]]: - """Open Captures - helper function to open all capture objects, set the framerate, - and ensure that all open captures have been opened and the framerates match on a list - of video file paths, or a list containing a single device ID. - - Arguments: - video_files: List of one or more paths (str), or a list - of a single integer device ID, to open as an OpenCV VideoCapture object. - A ValueError will be raised if the list does not conform to the above. - framerate: Framerate to assume when opening the video_files. - If not set, the first open video is used for deducing the framerate of - all videos in the sequence. - validate_parameters (bool, optional): If true, will ensure that the frame sizes - (width, height) and frame rate (FPS) of all passed videos is the same. - A VideoParameterMismatch is raised if the framerates do not match. - - Returns: - A tuple of form (cap_list, framerate, framesize) where cap_list is a list of open - OpenCV VideoCapture objects in the same order as the video_files list, framerate - is a float of the video(s) framerate(s), and framesize is a tuple of (width, height) - where width and height are integers representing the frame size in pixels. - - Raises: - ValueError: No video file(s) specified, or invalid/multiple device IDs specified. - TypeError: `framerate` must be type `float`. - IOError: Video file(s) not found. - FrameRateUnavailable: Video framerate could not be obtained and `framerate` - was not set manually. - VideoParameterMismatch: All videos in `video_files` do not have equal parameters. - Set `validate_parameters=False` to skip this check. - VideoOpenFailure: Video(s) could not be opened. - """ - is_device = False - if not video_files: - raise ValueError("Expected at least 1 video file or device ID.") - if isinstance(video_files[0], int): - if len(video_files) > 1: - raise ValueError("If device ID is specified, no video sources may be appended.") - elif video_files[0] < 0: - raise ValueError("Invalid/negative device ID specified.") - is_device = True - elif not all([isinstance(video_file, (str, bytes)) for video_file in video_files]): - print(video_files) - raise ValueError("Unexpected element type in video_files list (expected str(s)/int).") - elif framerate is not None and not isinstance(framerate, float): - raise TypeError("Expected type float for parameter framerate.") - # Check if files exist if passed video file is not an image sequence - # (checked with presence of % in filename) or not a URL (://). - if not is_device and any( - [ - not os.path.exists(video_file) - for video_file in video_files - if not ("%" in video_file or "://" in video_file) - ] - ): - raise OSError("Video file(s) not found.") - cap_list = [] - - try: - cap_list = [cv2.VideoCapture(video_file) for video_file in video_files] - video_names = [get_video_name(video_file) for video_file in video_files] - closed_caps = [video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened()] - if closed_caps: - raise VideoOpenFailure(str(closed_caps)) - - cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list] - cap_framerate, check_framerate = validate_capture_framerate( - video_names, cap_framerates, framerate - ) - # Store frame sizes as integers (VideoCapture.get() returns float). - cap_frame_sizes = [ - ( - math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) - for cap in cap_list - ] - cap_frame_size = cap_frame_sizes[0] - - # If we need to validate the parameters, we check that the FPS and width/height - # of all open captures is identical (or almost identical in the case of FPS). - if validate_parameters: - validate_capture_parameters( - video_names=video_names, - cap_frame_sizes=cap_frame_sizes, - check_framerate=check_framerate, - cap_framerates=cap_framerates, - ) - - except: - for cap in cap_list: - cap.release() - raise - - return (cap_list, cap_framerate, cap_frame_size) - - -def validate_capture_framerate( - video_names: Iterable[Tuple[str, str]], - cap_framerates: List[float], - framerate: Optional[float] = None, -) -> Tuple[float, bool]: - """Ensure the passed capture framerates are valid and equal. - - Raises: - ValueError: Invalid framerate (must be positive non-zero value). - TypeError: Framerate must be of type float. - FrameRateUnavailable: Framerate for video could not be obtained, - and `framerate` was not set. - """ - check_framerate = True - cap_framerate = cap_framerates[0] - if framerate is not None: - if isinstance(framerate, float): - if framerate < MAX_FPS_DELTA: - raise ValueError("Invalid framerate (must be a positive non-zero value).") - cap_framerate = framerate - check_framerate = False - else: - raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__) - else: - unavailable_framerates = [ - (video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if fps < MAX_FPS_DELTA - ] - if unavailable_framerates: - raise FrameRateUnavailable() - return (cap_framerate, check_framerate) - - -def validate_capture_parameters( - video_names: List[Tuple[str, str]], - cap_frame_sizes: List[Tuple[int, int]], - check_framerate: bool = False, - cap_framerates: Optional[List[float]] = None, -) -> None: - """Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) - framerates are equal. Raises VideoParameterMismatch if there is a mismatch. - - Raises: - VideoParameterMismatch - """ - bad_params = [] - max_framerate_delta = MAX_FPS_DELTA - # Check heights/widths match. - bad_params += [ - ( - cv2.CAP_PROP_FRAME_WIDTH, - frame_size[0], - cap_frame_sizes[0][0], - video_names[i][0], - video_names[i][1], - ) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0 - ] - bad_params += [ - ( - cv2.CAP_PROP_FRAME_HEIGHT, - frame_size[1], - cap_frame_sizes[0][1], - video_names[i][0], - video_names[i][1], - ) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0 - ] - # Check framerates if required. - if check_framerate: - bad_params += [ - (cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if math.fabs(fps - cap_framerates[0]) > max_framerate_delta - ] - - if bad_params: - raise VideoParameterMismatch(bad_params) - - -## -## VideoManager Class Implementation -## - - -class VideoManager(VideoStream): - """[DEPRECATED] DO NOT USE. - - Provides a cv2.VideoCapture-like interface to a set of one or more video files, - or a single device ID. Supports seeking and setting end time/duration.""" - - BACKEND_NAME = "video_manager_do_not_use" - - def __init__( - self, - video_files: List[str], - framerate: Optional[float] = None, - logger=None, - ): - """[DEPRECATED] DO NOT USE. - - Arguments: - video_files (list of str(s)/int): A list of one or more paths (str), or a list - of a single integer device ID, to open as an OpenCV VideoCapture object. - framerate (float, optional): Framerate to assume when storing FrameTimecodes. - If not set (i.e. is None), it will be deduced from the first open capture - in video_files, else raises a FrameRateUnavailable exception. - - Raises: - ValueError: No video file(s) specified, or invalid/multiple device IDs specified. - TypeError: `framerate` must be type `float`. - IOError: Video file(s) not found. - FrameRateUnavailable: Video framerate could not be obtained and `framerate` - was not set manually. - VideoParameterMismatch: All videos in `video_files` do not have equal parameters. - Set `validate_parameters=False` to skip this check. - VideoOpenFailure: Video(s) could not be opened. - """ - # TODO(v0.7): Add DeprecationWarning that this class will be removed in v0.8: 'VideoManager - # will be removed in PySceneDetect v0.8. Use VideoStreamCv2 or VideoCaptureAdapter instead.' - if logger is None: - logger = getLogger("pyscenedetect") - logger.error("VideoManager is deprecated and will be removed.") - if not video_files: - raise ValueError("At least one string/integer must be passed in the video_files list.") - # Need to support video_files as a single str too for compatibility. - if isinstance(video_files, str): - video_files = [video_files] - # These VideoCaptures are only open in this process. - self._is_device = isinstance(video_files[0], int) - self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=video_files, framerate=framerate - ) - self._path = video_files[0] if not self._is_device else video_files - self._end_of_video = False - self._start_time = self.get_base_timecode() - self._end_time = None - self._curr_time = self.get_base_timecode() - self._last_frame = None - self._curr_cap, self._curr_cap_idx = None, None - self._video_file_paths = video_files - self._logger = logger - if self._logger is not None: - self._logger.info( - "Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d", - len(self._cap_list), - "s" if len(self._cap_list) > 1 else "", - self.get_framerate(), - *self.get_framesize(), - ) - self._started = False - self._frame_length = self.get_base_timecode() + get_num_frames(self._cap_list) - self._first_cap_len = self.get_base_timecode() + get_num_frames([self._cap_list[0]]) - self._aspect_ratio = _get_aspect_ratio(self._cap_list[0]) - - def set_downscale_factor(self, downscale_factor=None): - """No-op. Set downscale_factor in `SceneManager` instead.""" - _ = downscale_factor - - def get_num_videos(self) -> int: - """Get the length of the internal capture list, - representing the number of videos the VideoManager was constructed with. - - Returns: - int: Number of videos, equal to length of capture list. - """ - return len(self._cap_list) - - def get_video_paths(self) -> List[str]: - """Get list of strings containing paths to the open video(s). - - Returns: - List[str]: List of paths to the video files opened by the VideoManager. - """ - return list(self._video_file_paths) - - def get_video_name(self) -> str: - """Get name of the video based on the first video path. - - Returns: - The base name of the video file, without extension. - """ - video_paths = self.get_video_paths() - if not video_paths: - return "" - video_name = os.path.basename(video_paths[0]) - if video_name.rfind(".") >= 0: - video_name = video_name[: video_name.rfind(".")] - return video_name - - def get_framerate(self) -> float: - """Get the framerate the VideoManager is assuming for all - open VideoCaptures. Obtained from either the capture itself, or the passed - framerate parameter when the VideoManager object was constructed. - - Returns: - Framerate, in frames/sec. - """ - return self._cap_framerate - - def get_base_timecode(self) -> FrameTimecode: - """Get a FrameTimecode object at frame 0 / time 00:00:00. - - The timecode returned by this method can be used to perform arithmetic (e.g. - addition), passing the resulting values back to the VideoManager (e.g. for the - :meth:`set_duration()` method), as the framerate of the returned FrameTimecode - object matches that of the VideoManager. - - As such, this method is equivalent to creating a FrameTimecode at frame 0 with - the VideoManager framerate, for example, given a VideoManager called obj, - the following expression will evaluate as True: - - obj.get_base_timecode() == FrameTimecode(0, obj.get_framerate()) - - Furthermore, the base timecode object returned by a particular VideoManager - should not be passed to another one, unless you first verify that their - framerates are the same. - - Returns: - FrameTimecode at frame 0/time 00:00:00 with the video(s) framerate. - """ - return FrameTimecode(timecode=0, fps=self._cap_framerate) - - def get_current_timecode(self) -> FrameTimecode: - """Get Current Timecode - returns a FrameTimecode object at current VideoManager position. - - Returns: - Timecode at the current VideoManager position. - """ - return self._curr_time - - def get_framesize(self) -> Tuple[int, int]: - """Get frame size of the video(s) open in the VideoManager's capture objects. - - Returns: - Video frame size, in pixels, in the form (width, height). - """ - return self._cap_framesize - - def get_framesize_effective(self) -> Tuple[int, int]: - """Get Frame Size - returns the frame size of the video(s) open in the - VideoManager's capture objects. - - Returns: - Video frame size, in pixels, in the form (width, height). - """ - return self._cap_framesize - - def set_duration( - self, - duration: Optional[FrameTimecode] = None, - start_time: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, - ) -> None: - """Set Duration - sets the duration/length of the video(s) to decode, as well as - the start/end times. Must be called before :meth:`start()` is called, otherwise - a VideoDecodingInProgress exception will be thrown. May be called after - :meth:`reset()` as well. - - Arguments: - duration (Optional[FrameTimecode]): The (maximum) duration in time to - decode from the opened video(s). Mutually exclusive with end_time - (i.e. if duration is set, end_time must be None). - start_time (Optional[FrameTimecode]): The time/first frame at which to - start decoding frames from. If set, the input video(s) will be - seeked to when start() is called, at which point the frame at - start_time can be obtained by calling retrieve(). - end_time (Optional[FrameTimecode]): The time at which to stop decoding - frames from the opened video(s). Mutually exclusive with duration - (i.e. if end_time is set, duration must be None). - - Raises: - VideoDecodingInProgress: Must call before start(). - """ - if self._started: - raise VideoDecodingInProgress() - - # Ensure any passed timecodes have the proper framerate. - if ( - (duration is not None and not duration.equal_framerate(self._cap_framerate)) - or (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) - or (end_time is not None and not end_time.equal_framerate(self._cap_framerate)) - ): - raise ValueError("FrameTimecode framerate does not match.") - - if duration is not None and end_time is not None: - raise TypeError("Only one of duration and end_time may be specified, not both.") - - if start_time is not None: - self._start_time = start_time - - if end_time is not None: - if end_time < self._start_time: - raise ValueError("end_time is before start_time in time.") - self._end_time = end_time - elif duration is not None: - self._end_time = self._start_time + duration - - if self._end_time is not None: - self._frame_length = min(self._frame_length, self._end_time + 1) - self._frame_length -= self._start_time - - if self._logger is not None: - self._logger.info( - "Duration set, start: %s, duration: %s, end: %s.", - start_time.get_timecode() if start_time is not None else start_time, - duration.get_timecode() if duration is not None else duration, - end_time.get_timecode() if end_time is not None else end_time, - ) - - def get_duration(self) -> FrameTimecode: - """Get Duration - gets the duration/length of the video(s) to decode, - as well as the start/end times. - - If the end time was not set by :meth:`set_duration()`, the end timecode - is calculated as the start timecode + total duration. - - Returns: - Tuple[FrameTimecode, FrameTimecode, FrameTimecode]: The current video(s) - total duration, start timecode, and end timecode. - """ - end_time = self._end_time - if end_time is None: - end_time = self.get_base_timecode() + self._frame_length - return (self._frame_length, self._start_time, end_time) - - def start(self) -> None: - """Start - starts video decoding and seeks to start time. Raises - exception VideoDecodingInProgress if the method is called after the - decoder process has already been started. - - Raises: - VideoDecodingInProgress: Must call :meth:`stop()` before this - method if :meth:`start()` has already been called after - initial construction. - """ - if self._started: - raise VideoDecodingInProgress() - - self._started = True - self._get_next_cap() - if self._start_time != 0: - self.seek(self._start_time) - - # This overrides the seek method from the VideoStream interface, but the name was changed - # from `timecode` to `target`. For compatibility, we allow calling seek with the form - # seek(0), seek(timecode=0), and seek(target=0). Specifying both arguments is an error. - def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> bool: - """Seek forwards to the passed timecode. - - Only supports seeking forwards (i.e. timecode must be greater than the - current position). Can only be used after the :meth:`start()` - method has been called. - - Arguments: - timecode: Time in video to seek forwards to. Only one of timecode or target can be set. - target: Same as timecode. Only one of timecode or target can be set. - - Returns: - bool: True if seeking succeeded, False if no more frames / end of video. - - Raises: - ValueError: Either none or both `timecode` and `target` were set. - """ - if timecode is None and target is None: - raise ValueError("`target` must be set.") - if timecode is not None and target is not None: - raise ValueError("Only one of `timecode` or `target` can be set.") - if target is not None: - timecode = target - assert timecode is not None - if timecode < 0: - raise ValueError("Target seek position cannot be negative!") - - if not self._started: - self.start() - - timecode = self.base_timecode + timecode - if self._end_time is not None and timecode > self._end_time: - timecode = self._end_time - - # TODO: Seeking only works for the first (or current) video in the VideoManager. - # Warn the user there are multiple videos in the VideoManager, and the requested - # seek time exceeds the length of the first video. - if len(self._cap_list) > 1 and timecode > self._first_cap_len: - # TODO: This should throw an exception instead of potentially failing silently - # if no logger was provided. - if self._logger is not None: - self._logger.error("Seeking past the first input video is not currently supported.") - self._logger.warning("Seeking to end of first input.") - timecode = self._first_cap_len - if self._curr_cap is not None and self._end_of_video is not True: - self._curr_cap.set(cv2.CAP_PROP_POS_FRAMES, timecode.get_frames() - 1) - self._curr_time = timecode - 1 - - while self._curr_time < timecode: - if not self.grab(): - return False - return True - - def release(self) -> None: - """Release (cv2.VideoCapture method), releases all open capture(s).""" - for cap in self._cap_list: - cap.release() - self._cap_list = [] - self._started = False - - def reset(self) -> None: - """Reset - Reopens captures passed to the constructor of the VideoManager. - - Can only be called after the :meth:`release()` method has been called. - - Raises: - VideoDecodingInProgress: Must call :meth:`release()` before this method. - """ - if self._started: - self.release() - - self._started = False - self._end_of_video = False - self._curr_time = self.get_base_timecode() - self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=self._video_file_paths, framerate=self._curr_time.get_framerate() - ) - self._curr_cap, self._curr_cap_idx = None, None - - def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, int]: - """Get (cv2.VideoCapture method) - obtains capture properties from the current - VideoCapture object in use. Index represents the same index as the original - video_files list passed to the constructor. Getting/setting the position (POS) - properties has no effect; seeking is implemented using VideoDecoder methods. - - Note that getting the property CAP_PROP_FRAME_COUNT will return the integer sum of - the frame count for all VideoCapture objects if index is not specified (or is None), - otherwise the frame count for the given VideoCapture index is returned instead. - - Arguments: - capture_prop: OpenCV VideoCapture property to get (i.e. CAP_PROP_FPS). - index (int, optional): Index in file_list of capture to get property from (default - is zero). Index is not checked and will raise exception if out of bounds. - - Returns: - float: Return value from calling get(property) on the VideoCapture object. - """ - if capture_prop == cv2.CAP_PROP_FRAME_COUNT and index is None: - return self._frame_length.get_frames() - elif capture_prop == cv2.CAP_PROP_POS_FRAMES: - return self._curr_time - elif capture_prop == cv2.CAP_PROP_FPS: - return self._cap_framerate - elif index is None: - index = 0 - return self._cap_list[index].get(capture_prop) - - def grab(self) -> bool: - """Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. - - Returns: - bool: True if a frame was grabbed, False otherwise. - """ - if not self._started: - self.start() - - grabbed = False - if self._curr_cap is not None and not self._end_of_video: - while not grabbed: - grabbed = self._curr_cap.grab() - if not grabbed and not self._get_next_cap(): - break - if self._end_time is not None and self._curr_time > self._end_time: - grabbed = False - self._last_frame = None - if grabbed: - self._curr_time += 1 - else: - self._correct_frame_length() - return grabbed - - def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: - """Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. - - Frame returned corresponds to last call to :meth:`grab()`. - - Returns: - Tuple of (True, frame_image) if a frame was grabbed during the last call to grab(), - and where frame_image is a numpy np.ndarray of the decoded frame. Otherwise (False, None). - """ - if not self._started: - self.start() - - retrieved = False - if self._curr_cap is not None and not self._end_of_video: - while not retrieved: - retrieved, self._last_frame = self._curr_cap.retrieve() - if not retrieved and not self._get_next_cap(): - break - if self._end_time is not None and self._curr_time > self._end_time: - retrieved = False - self._last_frame = None - return (retrieved, self._last_frame) - - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: - """Return next frame (or current if advance = False), or False if end of video. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will remain on the current frame. - - Returns: - If decode = True, returns either the decoded frame, or False if end of video. - If decode = False, a boolean indicating if the next frame was advanced to or not is - returned. - """ - if not self._started: - self.start() - has_grabbed = False - if advance: - has_grabbed = self.grab() - if decode: - retrieved, frame = self.retrieve() - return frame if retrieved else False - return has_grabbed - - def _get_next_cap(self) -> bool: - self._curr_cap = None - if self._curr_cap_idx is None: - self._curr_cap_idx = 0 - self._curr_cap = self._cap_list[0] - return True - else: - if not (self._curr_cap_idx + 1) < len(self._cap_list): - self._end_of_video = True - return False - self._curr_cap_idx += 1 - self._curr_cap = self._cap_list[self._curr_cap_idx] - return True - - def _correct_frame_length(self) -> None: - """Checks if the current frame position exceeds that originally calculated, - and adjusts the internally calculated frame length accordingly. Called after - exhausting all input frames from the video source(s). - """ - self._end_time = self._curr_time - self._frame_length = self._curr_time - self._start_time - - # VideoStream Interface (Some Covered Above) - - @property - def aspect_ratio(self) -> float: - """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - return self._aspect_ratio - - @property - def duration(self) -> Optional[FrameTimecode]: - """Duration of the stream as a FrameTimecode, or None if non terminating.""" - return self.get_duration()[0] - - @property - def position(self) -> FrameTimecode: - """Current position within stream as FrameTimecode. - - This can be interpreted as presentation time stamp of the last frame which was - decoded by calling `read` with advance=True. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" - frames = self._curr_time.get_frames() - if frames < 1: - return self.base_timecode - return self.base_timecode + (frames - 1) - - @property - def position_ms(self) -> float: - """Current position within stream as a float of the presentation time in milliseconds. - The first frame has a time of 0.0 ms. - - This method will always return 0.0 if no frames have been `read`.""" - return self.position.get_seconds() * 1000.0 - - @property - def frame_number(self) -> int: - """Current position within stream in frames as an int. - - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" - return self._curr_time.get_frames() - - @property - def frame_rate(self) -> float: - """Framerate in frames/sec.""" - return self._cap_framerate - - @property - 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_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) - - @property - def is_seekable(self) -> bool: - """Just returns True.""" - return True - - @property - def path(self) -> Union[bytes, str]: - """Video or device path.""" - if self._is_device: - return "Device %d" % self._path - return self._path - - @property - def name(self) -> Union[bytes, str]: - """Name of the video, without extension, or device.""" - if self._is_device: - return self.path - return get_file_name(self.path, include_extension=False) diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index bbca1f1c..563ea269 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -5,383 +5,18 @@ # [ 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. # -# This software may also invoke mkvmerge or FFmpeg, if available. -# FFmpeg is a trademark of Fabrice Bellard. -# mkvmerge is Copyright (C) 2005-2016, Matroska. -# Certain distributions of PySceneDetect may include the above software; -# see the included LICENSE-FFMPEG and LICENSE-MKVMERGE files. -# -"""``scenedetect.video_splitter`` Module - -The `scenedetect.video_splitter` module contains functions to split existing videos into clips -using ffmpeg or mkvmerge. - -These programs can be obtained from following URLs (note that mkvmerge is a part mkvtoolnix): - - * FFmpeg: [ https://ffmpeg.org/download.html ] - * mkvmerge: [ https://mkvtoolnix.download/downloads.html ] - -If you are a Linux user, you can likely obtain the above programs from your package manager. - -Once installed, ensure the program can be accessed system-wide by calling the `mkvmerge` or `ffmpeg` -command from a terminal/command prompt. PySceneDetect will automatically use whichever program is -available on the computer, depending on the specified command-line options. -""" - -import logging -import math -import subprocess -import time -import typing as ty -from dataclasses import dataclass -from pathlib import Path +"""DEPRECATED""" -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.platform import CommandTooLong, Template, get_ffmpeg_path, invoke_command, tqdm +import warnings -logger = logging.getLogger("pyscenedetect") - -TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] -"""Named type for pairs of timecodes, which typically represents the start/end of a scene.""" - -COMMAND_TOO_LONG_STRING = """ -Cannot split video due to too many scenes (resulting command -is too large to process). To work around this issue, you can -split the video manually by exporting a list of cuts with the -`list-scenes` command. -See https://github.com/Breakthrough/PySceneDetect/issues/164 -for details. Sorry about that! -""" - -FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() -"""Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" - -DEFAULT_FFMPEG_ARGS = ( - "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac" +warnings.warn( + "The `video_splitter` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, ) -"""Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" - -## -## Command Availability Checking Functions -## - - -def is_mkvmerge_available() -> bool: - """Is mkvmerge Available: Gracefully checks if mkvmerge command is available. - - 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 - - -def is_ffmpeg_available() -> bool: - """Is ffmpeg Available: Gracefully checks if ffmpeg command is available. - - Returns: - True if `ffmpeg` can be invoked, False otherwise. - """ - return FFMPEG_PATH is not None - - -## -## Output Naming -## - - -@dataclass -class VideoMetadata: - """Information about the video being split.""" - - name: str - """Expected name of the video. May differ from `path`.""" - path: Path - """Path to the input file.""" - total_scenes: int - """Total number of scenes that will be written.""" - - -@dataclass -class SceneMetadata: - """Information about the scene being extracted.""" - - index: int - """0-based index of this scene.""" - start: FrameTimecode - """First frame.""" - end: FrameTimecode - """Last frame.""" - - -PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], ty.AnyStr] - - -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` - """ - 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.get_frames()), - END_FRAME=str(scene.end.get_frames()), - ) - return formatter - - -## -## Split Video Functions -## - - -def split_video_mkvmerge( - input_video_path: str, - scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[Path] = None, - output_file_template: str = "$VIDEO_NAME.mkv", - video_name: ty.Optional[str] = None, - show_output: bool = False, - suppress_output=None, -) -> int: - """Calls the mkvmerge command on the input video, splitting it at the - passed timecodes, where each scene is written in sequence from 001. - - Arguments: - input_video_path: Path to the video to be split. - scene_list : List of scenes as pairs of FrameTimecodes denoting the start/end times. - output_dir: Directory to output videos. If not set, output will be in working directory. - output_file_template: Template to use for generating output files. Note that mkvmerge always - adds the suffix "-$SCENE_NUMBER" to the output paths. Only the $VIDEO_NAME variable - is supported by this function. - video_name (str): Name of the video to be substituted in output_file_template for - $VIDEO_NAME. If not specified, will be obtained from the filename. - show_output: If False, adds the --quiet flag when invoking `mkvmerge`. - suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. - Returns: - Return code of invoking mkvmerge (0 on success). If scene_list is empty, will - still return 0, but no commands will be invoked. - """ - # Handle backwards compatibility with v0.5 API. - if isinstance(input_video_path, list): - logger.error("Using a list of paths is deprecated. Pass a single path instead.") - if len(input_video_path) > 1: - raise ValueError("Concatenating multiple input videos is not supported.") - input_video_path = input_video_path[0] - if suppress_output is not None: - logger.error("suppress_output is deprecated, use show_output instead.") - show_output = not suppress_output - - if not scene_list: - return 0 - - logger.info("Splitting video with mkvmerge, output path template:\n %s", output_file_template) - if output_dir: - logger.info("Output folder:\n %s", output_file_template) - - if video_name is None: - video_name = Path(input_video_path).stem - - ret_val = 0 - - # mkvmerge doesn't support adding scene metadata to filenames. It always adds the scene - # number prefixed with a dash to the filenames. - template = Template(output_file_template) - output_path = template.safe_substitute(VIDEO_NAME=video_name) - if output_dir: - output_path = Path(output_dir) / output_path - output_path.parent.mkdir(parents=True, exist_ok=True) - - try: - call_list = ["mkvmerge"] - if not show_output: - call_list.append("--quiet") - call_list += [ - "-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 - ] - ), - input_video_path, - ] - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() - processing_start_time = time.time() - # TODO: Capture stdout/stderr and show that if the command fails. - ret_val = invoke_command(call_list) - if show_output: - logger.info( - "Average processing speed %.2f frames/sec.", - float(total_frames) / (time.time() - processing_start_time), - ) - except CommandTooLong: - logger.error(COMMAND_TOO_LONG_STRING) - except OSError: - logger.error( - "mkvmerge could not be found on the system." - " Please install mkvmerge to enable video output support." - ) - if ret_val != 0: - logger.error("Error splitting video (mkvmerge returned %d).", ret_val) - return ret_val - - -def split_video_ffmpeg( - input_video_path: str, - scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[Path] = None, - output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", - video_name: ty.Optional[str] = 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, -) -> int: - """Calls the ffmpeg command on the input video, generating a new video for - each scene based on the start/end timecodes. - - Arguments: - input_video_path: Path to the video to be split. - scene_list (List[ty.Tuple[FrameTimecode, FrameTimecode]]): List of scenes - (pairs of FrameTimecodes) denoting the start/end frames of each scene. - output_dir: Directory to output videos. If not set, output will be in working directory. - output_file_template (str): Template to use for generating output filenames. - The following variables will be replaced in the template for each scene: - $VIDEO_NAME, $SCENE_NUMBER, $START_TIME, $END_TIME, $START_FRAME, $END_FRAME - video_name (str): Name of the video to be substituted in output_file_template. If not - passed will be calculated from input_video_path automatically. - arg_override (str): Allows overriding the arguments passed to ffmpeg for encoding. - show_progress (bool): If True, will show progress bar provided by tqdm (if installed). - show_output (bool): If True, will show output from ffmpeg for first split. - suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. - hide_progress: [DEPRECATED] DO NOT USE. For backwards compatibility only. - formatter: Custom formatter callback. Overrides `output_file_template`. - - Returns: - Return code of invoking ffmpeg (0 on success). If scene_list is empty, will - still return 0, but no commands will be invoked. - """ - # Handle backwards compatibility with v0.5 API. - if isinstance(input_video_path, list): - logger.error("Using a list of paths is deprecated. Pass a single path instead.") - if len(input_video_path) > 1: - raise ValueError("Concatenating multiple input videos is not supported.") - input_video_path = input_video_path[0] - if suppress_output is not None: - logger.error("suppress_output is deprecated, use show_output instead.") - show_output = not suppress_output - if hide_progress is not None: - logger.error("hide_progress is deprecated, use show_progress instead.") - show_progress = not hide_progress - - if not scene_list: - return 0 - - logger.info("Splitting video with ffmpeg, output path template:\n %s", output_file_template) - if output_dir: - logger.info("Output folder:\n %s", output_file_template) - - if video_name is None: - video_name = Path(input_video_path).stem - - arg_override = arg_override.replace('\\"', '"') - - ret_val = 0 - arg_override = 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) - ) - - try: - progress_bar = None - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() - if show_progress: - progress_bar = tqdm(total=total_frames, unit="frame", miniters=1, dynamic_ncols=True) - processing_start_time = time.time() - 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)) - if output_dir: - output_path = Path(output_dir) / output_path - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Gracefully handle case where FFMPEG_PATH might be unset. - call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else "ffmpeg"] - if not show_output: - call_list += ["-v", "quiet"] - elif i > 0: - # Only show ffmpeg output for the first call, which will display any - # errors if it fails, and then break the loop. We only show error messages - # for the remaining calls. - call_list += ["-v", "error"] - call_list += [ - "-nostdin", - "-y", - "-ss", - str(start_time.get_seconds()), - "-i", - input_video_path, - "-t", - str(duration.get_seconds()), - ] - call_list += arg_override - call_list += ["-sn"] - call_list += [str(output_path)] - ret_val = invoke_command(call_list) - if show_output and i == 0 and len(scene_list) > 1: - logger.info( - "Output from ffmpeg for Scene 1 shown above, splitting remaining scenes..." - ) - if ret_val != 0: - # TODO: Capture stdout/stderr and display it on any failed calls. - logger.error("Error splitting video (ffmpeg returned %d).", ret_val) - break - if progress_bar: - progress_bar.update(duration.get_frames()) - - if progress_bar: - progress_bar.close() - if show_output: - logger.info( - "Average processing speed %.2f frames/sec.", - float(total_frames) / (time.time() - processing_start_time), - ) - except CommandTooLong: - logger.error(COMMAND_TOO_LONG_STRING) - except OSError: - logger.error( - "ffmpeg could not be found on the system." - " Please install ffmpeg to enable video output support." - ) - return ret_val +from scenedetect.output.video import * # noqa: E402, F403 diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 8d188daf..48f25087 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. # @@ -31,16 +31,13 @@ tested by adding it to the test suite in `tests/test_video_stream.py`. """ +import typing as ty from abc import ABC, abstractmethod -from typing import Optional, Tuple, Union +from fractions import Fraction import numpy as np -from scenedetect.frame_timecode import FrameTimecode - -## -## VideoStream Exceptions -## +from scenedetect.common import FrameTimecode, TimecodeLike class SeekError(Exception): @@ -49,6 +46,8 @@ class SeekError(Exception): The stream is guaranteed to be left in a valid state, but the position may be reset.""" + ... + class VideoOpenFailure(Exception): """Raised by a backend if opening a video fails.""" @@ -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'`).""" - raise NotImplementedError + BACKEND_NAME: ty.ClassVar[str] + """Unique name used to identify this backend. Each subclass must set this to a unique str.""" # # Abstract Properties @@ -106,45 +111,45 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> Union[bytes, str]: + def path(self) -> str: """Video or device path.""" - raise NotImplementedError + ... @property @abstractmethod - def name(self) -> Union[bytes, str]: + def name(self) -> str: """Name of the video, without extension, or device.""" - raise NotImplementedError + ... @property @abstractmethod def is_seekable(self) -> bool: """True if seek() is allowed, False otherwise.""" - raise NotImplementedError + ... @property @abstractmethod - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" - raise NotImplementedError + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction (e.g. Fraction(24000, 1001)).""" + ... @property @abstractmethod - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - raise NotImplementedError + ... @property @abstractmethod - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - raise NotImplementedError + ... @property @abstractmethod def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" - raise NotImplementedError + ... @property @abstractmethod @@ -153,14 +158,14 @@ 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.""" - raise NotImplementedError + ... @property @abstractmethod 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.""" - raise NotImplementedError + ... @property @abstractmethod @@ -168,33 +173,34 @@ def frame_number(self) -> int: """Current position within stream as the frame number. Will return 0 until the first frame is `read`.""" - raise NotImplementedError + ... # # Abstract Methods # @abstractmethod - def read(self, decode: bool = True, advance: bool = True) -> 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: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. + decode: Return the frame image itself. If False, a boolean indicating if the stream + was advanced to the next frame or not. This can improve performance by reducing + memory copying and colorspace conversions when a given frame's data is not required. Returns: If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ - raise NotImplementedError + ... @abstractmethod def reset(self) -> None: """Close and re-open the VideoStream (equivalent to seeking back to beginning).""" - raise NotImplementedError + ... @abstractmethod - def seek(self, target: 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). @@ -213,4 +219,4 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: SeekError: An error occurs while seeking, or seeking is not supported. ValueError: `target` is not a valid value (i.e. it is negative). """ - raise NotImplementedError + ... 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/scripts/pre_release.py b/scripts/pre_release.py new file mode 100644 index 00000000..67f58b98 --- /dev/null +++ b/scripts/pre_release.py @@ -0,0 +1,123 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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` when building the Windows distribution: +```bash +python scripts/pre_release.py +pyinstaller packaging/windows/scenedetect.spec +``` +""" + +import sys +import tempfile +from pathlib import Path + +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 + +if run_version_check: + 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 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") + (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 + if not pat.isdigit(): + 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 +VSVersionInfo( + ffi=FixedFileInfo( +# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) +# Set not needed items to zero 0. +filevers=({maj}, {min}, {pat}, {bld}), +prodvers=({maj}, {min}, {pat}, {bld}), +# Contains a bitmask that specifies the valid bits 'flags'r +mask=0x3f, +# Contains a bitmask that specifies the Boolean attributes of the file. +flags=0x0, +# The operating system for which this file was designed. +# 0x4 - NT and there is no need to change it. +OS=0x4, +# The general type of file. +# 0x1 - the file is an application. +fileType=0x1, +# The function of the file. +# 0x0 - the function is not defined for this fileType +subtype=0x0, +# Creation date and time stamp. +date=(0, 0) +), + kids=[ +StringFileInfo( + [ + StringTable( + u'040904B0', + [StringStruct(u'CompanyName', u'github.com/Breakthrough'), + StringStruct(u'FileDescription', u'www.scenedetect.com'), + StringStruct(u'FileVersion', u'{VERSION}'), + StringStruct(u'InternalName', u'PySceneDetect'), + StringStruct(u'LegalCopyright', u'Copyright © 2024 Brandon Castellano'), + StringStruct(u'OriginalFilename', u'scenedetect.exe'), + StringStruct(u'ProductName', u'PySceneDetect'), + StringStruct(u'ProductVersion', u'{VERSION}')]) + ]), +VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) + ] +) +""".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 75451308..00000000 --- a/setup.cfg +++ /dev/null @@ -1,67 +0,0 @@ - -[metadata] -name = scenedetect -version = attr: scenedetect.__version__ -license = BSD 3-Clause License -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 - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Multimedia :: Video - Topic :: Multimedia :: Video :: Conversion - Topic :: Multimedia :: Video :: Non-Linear Editor - Topic :: Utilities -keywords = video computer-vision analysis - -[options] -install_requires = - Click - numpy - platformdirs - tqdm -packages = - scenedetect - scenedetect._cli - scenedetect._thirdparty - scenedetect.backends - scenedetect.detectors -python_requires = >=3.7 - -[options.extras_require] -opencv = opencv-python -opencv-headless = opencv-python-headless -pyav = av -moviepy = moviepy - -[options.entry_points] -console_scripts = - scenedetect = scenedetect.__main__:main - -[aliases] -test = pytest - -[tool:pytest] -addopts = --verbose -python_files = tests/*.py 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 6034456c..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. # @@ -28,16 +28,22 @@ import logging import os -from typing import AnyStr +import typing as ty 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 # -def check_exists(path: AnyStr) -> AnyStr: +def check_exists(path: ty.AnyStr) -> ty.AnyStr: """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. @@ -45,14 +51,13 @@ def check_exists(path: AnyStr) -> 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 @@ -86,8 +91,7 @@ def pytest_assertrepr_compare(op, left, right): @pytest.fixture(autouse=True) def no_logs_gte_error(caplog): """Ensure no log messages with error severity or higher were reported during test execution.""" - # TODO: Remove exclusion for VideoManager module when removed from codebase. - EXCLUDED_MODULES = {"video_manager"} + EXCLUDED_MODULES = set() yield errors = [ record @@ -109,6 +113,23 @@ def test_movie_clip() -> str: return check_exists("tests/resources/goldeneye.mp4") +@pytest.fixture +def test_vfr_video() -> str: + """Movie clip containing fast cut, but encoded as variable framerate.""" + 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.""" @@ -131,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 c353a96b..1ec880ec 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. # @@ -20,7 +20,7 @@ def test_api_detect(test_video_file: str): scene_list = detect(test_video_file, ContentDetector()) for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_detect_start_end_time(test_video_file: str): @@ -31,7 +31,7 @@ def test_api_detect_start_end_time(test_video_file: str): # See test_api_timecode_types() for examples of each format. scene_list = detect(test_video_file, ContentDetector(), start_time=10.5, end_time=15.9) for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_detect_stats(test_video_file: str): @@ -51,7 +51,7 @@ def test_api_scene_manager(test_video_file: str): scene_manager.detect_scenes(video=video) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_scene_manager_start_end_time(test_video_file: str): @@ -69,7 +69,20 @@ 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 soft-deprecated alias for `frame_rate=` (issue #548). + Both forms must produce equivalent streams; when both are provided, `frame_rate` wins.""" + from scenedetect import open_video + + legacy = open_video(test_video_file, framerate=30.0) + canonical = open_video(test_video_file, frame_rate=30.0) + assert legacy.frame_rate == canonical.frame_rate + # `frame_rate` takes precedence over `framerate` when both are provided. + both = open_video(test_video_file, frame_rate=30.0, framerate=24.0) + assert both.frame_rate == canonical.frame_rate def test_api_timecode_types(): @@ -79,16 +92,16 @@ def test_api_timecode_types(): base_timecode = FrameTimecode(timecode=0, fps=10.0) # Frames (int) timecode = base_timecode + 1 - assert timecode.get_frames() == 1 + assert timecode.frame_num == 1 # Seconds (float) timecode = base_timecode + 1.0 - assert timecode.get_frames() == 10 + assert timecode.frame_num == 10 # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') timecode = base_timecode + "00:00:01.500" - assert timecode.get_frames() == 15 + assert timecode.frame_num == 15 # Seconds (str, 'SSSs' or 'SSSS.SSSs') timecode = base_timecode + "1.5s" - assert timecode.get_frames() == 15 + assert timecode.frame_num == 15 def test_api_stats_manager(test_video_file: str): @@ -100,7 +113,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 +122,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 +141,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 +156,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 3f106ab8..1f8c03be 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. # @@ -31,6 +31,7 @@ def test_open_image_sequence(test_image_sequence: str): sequence = VideoStreamCv2(test_image_sequence, framerate=25.0) assert sequence.is_seekable assert sequence.frame_size[0] > 0 and sequence.frame_size[1] > 0 + assert sequence.duration is not None assert sequence.duration.frame_num == 30 assert sequence.read() is not False sequence.seek(100) @@ -49,4 +50,22 @@ def test_capture_adapter(test_movie_clip: str): assert scene_manager.detect_scenes(video=adapter, duration=adapter.base_timecode + 10.0) scenes = scene_manager.get_scene_list() assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST) - assert [start.get_frames() for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST + assert [start.frame_num for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST + + +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_backwards_compat.py b/tests/test_backwards_compat.py deleted file mode 100644 index 7e9036e3..00000000 --- a/tests/test_backwards_compat.py +++ /dev/null @@ -1,89 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""Test for compatibility with v0.5 API. - -Do not use this file as examples or in production code - see `test_api.py` instead. - -The whole API is not compatible, but the compatibility layer makes the high level examples -work without modification. -""" - -import logging -import os - -from scenedetect import ContentDetector, SceneManager, StatsManager, VideoManager -from scenedetect.platform import init_logger - - -def validate_backwards_compatibility(test_video_file: str, stats_file_path: str): - """Validate backwards compatibility wrapper for VideoManager. - - This is equivalent to the tests/api_test.py file from v0.5 with additional assertions. - Do not following this test for writing applications - see test_api.py for examples - using the current API. This test is equivalent to `test_api_stats_manager`. - """ - # Suppress errors generated by using deprecated classes/arguments below. - init_logger(log_level=logging.CRITICAL) - video_manager = VideoManager([test_video_file]) - stats_file_path = test_video_file + ".csv" - stats_manager = StatsManager() - scene_manager = SceneManager(stats_manager) - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - scene_list = [] - try: - start_time = base_timecode + 4.0 - end_time = base_timecode + 8.0 - - if os.path.exists(stats_file_path): - with open(stats_file_path) as stats_file: - stats_manager.load_from_csv(stats_file) - # ContentDetector requires at least 1 frame before it can calculate any metrics. - assert stats_manager.metrics_exist( - start_time.get_frames() + 1, [ContentDetector.FRAME_SCORE_KEY] - ) - # Correct end frame # for presentation duration. - assert stats_manager.metrics_exist( - end_time.get_frames() - 1, [ContentDetector.FRAME_SCORE_KEY] - ) - - video_manager.set_duration(start_time=start_time, end_time=end_time) - video_manager.set_downscale_factor() - video_manager.start() - assert video_manager.get_current_timecode().get_frames() == start_time.get_frames() - - scene_manager.detect_scenes(frame_source=video_manager) - scene_list = scene_manager.get_scene_list() - - # Correct end frame # for presentation duration. - assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1 - - if stats_manager.is_save_required(): - with open(stats_file_path, "w") as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode=base_timecode) - finally: - video_manager.release() - return scene_list - - -def test_backwards_compatibility_with_stats(test_video_file: str): - """Runs equivalent code to `tests/api_test.py` from v0.5 twice to also - exercise loading a statsfile from disk.""" - stats_file_path = test_video_file + ".csv" - if os.path.exists(stats_file_path): - os.remove(stats_file_path) - scenes = validate_backwards_compatibility(test_video_file, stats_file_path) - assert scenes - assert os.path.exists(stats_file_path) - # Make sure run with statsfile matches previous results. - assert validate_backwards_compatibility(test_video_file, stats_file_path) == scenes - os.remove(stats_file_path) 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 fcadb9bd..a807973e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,43 +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 glob import os import subprocess -import typing as ty -from pathlib import Path - -import cv2 -import pytest - -from scenedetect.video_splitter 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", @@ -67,8 +67,8 @@ def invoke_scenedetect( args: str = "", - output_dir: ty.Optional[str] = None, - config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, + output_dir: StrPath | None = None, + config_file: str | None = DEFAULT_CONFIG_FILE, **kwargs, ): """Invokes the scenedetect CLI with the specified arguments and returns the exit code. @@ -98,9 +98,9 @@ def invoke_scenedetect( value_dict.update(**kwargs) command = SCENEDETECT_CMD if output_dir: - command += " -o %s" % output_dir + command += f" -o {output_dir}" if config_file: - command += " -c %s" % config_file + command += f" -c {config_file}" command += " " + args.format(**value_dict) return subprocess.call(command.strip().split(" ")) @@ -115,6 +115,24 @@ def test_cli_default_detector(): assert invoke_scenedetect("-i {VIDEO} time {TIME}", config_file=None) == 0 +def test_cli_crop(): + """Test --crop functionality.""" + assert invoke_scenedetect("-i {VIDEO} --crop 0 0 256 256 time {TIME}", config_file=None) == 0 + + +def test_cli_crop_rejects_invalid(): + """Test --crop rejects invalid options.""" + # Outside of video bounds + assert ( + invoke_scenedetect("-i {VIDEO} --crop 4000 0 8000 100 time {TIME}", config_file=None) != 1 + ) + assert ( + invoke_scenedetect("-i {VIDEO} --crop 0 4000 100 8000 time {TIME}", config_file=None) != 1 + ) + # Negative numbers + assert invoke_scenedetect("-i {VIDEO} --crop 0 0 -256 -256 time {TIME}", config_file=None) != 1 + + @pytest.mark.parametrize("info_command", ["help", "about", "version"]) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about).""" @@ -149,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 @@ -177,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 @@ -222,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 @@ -234,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 ( @@ -284,34 +335,181 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): # and ensuring that we got some frames. +def test_cli_framerate_legacy_alias(): + """`--framerate` is the soft-deprecated hidden alias for `-f/--frame-rate` (issue #548). + Both forms must be accepted; passing both should not error.""" + # Canonical form. + exit_code, _ = invoke_cli( + ["-i", DEFAULT_VIDEO_PATH, "--frame-rate", "30.0", "time", "-s", "2s", "-d", "4s"] + ) + assert exit_code == 0 + # Legacy form. + exit_code, _ = invoke_cli( + ["-i", DEFAULT_VIDEO_PATH, "--framerate", "30.0", "time", "-s", "2s", "-d", "4s"] + ) + assert exit_code == 0 + # Both forms together: `--frame-rate` wins, a warning is logged but no error. + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "--frame-rate", + "30.0", + "--framerate", + "24.0", + "time", + "-s", + "2s", + "-d", + "4s", + ] + ) + assert exit_code == 0 + + +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.""" + 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 +Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds) +1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752 +2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252 +""" + assert output_path.read_text() == EXPECTED_CSV_OUTPUT + + +def test_cli_list_scenes_skip_cuts(tmp_path: Path): + """Test `list-scenes` command with the -s/--skip-cuts option for RFC 4180 compliance.""" # Regular invocation assert ( invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s", output_dir=tmp_path, ) == 0 ) - # Add statsfile + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") + assert os.path.exists(output_path) + EXPECTED_CSV_OUTPUT = """Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds) +1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752 +2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252 +""" + assert output_path.read_text() == EXPECTED_CSV_OUTPUT + + +def test_cli_list_scenes_no_output(tmp_path: Path): + """Test `list-scenes` command with the -n flag.""" + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") assert ( invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes", + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n", output_dir=tmp_path, ) == 0 ) - # Suppress output file + assert not os.path.exists(output_path) + + +def test_cli_list_scenes_custom_delimiter(tmp_path: Path): + """Test `list-scenes` command with custom delimiters set in a config file.""" + config_path = tmp_path.joinpath("config.cfg") + config_path.write_text(""" +[list-scenes] +col-separator = | +row-separator = \\t +""") assert ( invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n", + f"-i {{VIDEO}} -c {config_path} time {{TIME}} {{DETECTOR}} list-scenes", output_dir=tmp_path, ) == 0 ) - # TODO: Check for output files from regular invocation. - # TODO: Delete scene list and ensure is not recreated using -n. + 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 +Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds) +1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752 +2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252 +""" + EXPECTED_CSV_OUTPUT = EXPECTED_CSV_OUTPUT.replace(",", "|").replace("\n", "\t") + assert output_path.read_text() == EXPECTED_CSV_OUTPUT + + +def test_cli_list_scenes_rejects_multichar_col_separator(tmp_path: Path): + """Test `list-scenes` command with custom delimiters set in a config file.""" + config_path = tmp_path.joinpath("config.cfg") + config_path.write_text(""" +[list-scenes] +col-separator = || +""") + assert ( + invoke_scenedetect( + f"-i {{VIDEO}} -c {config_path} time {{TIME}} {{DETECTOR}} list-scenes", + output_dir=tmp_path, + ) + != 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") + assert not os.path.exists(output_path) @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") @@ -360,25 +558,44 @@ def test_cli_split_video_mkvmerge(tmp_path: Path): ) == 0 ) + for scene in range(DEFAULT_NUM_SCENES): + path = tmp_path / (Path(DEFAULT_VIDEO_PATH).stem + f"-Scene-{1 + scene:03d}.mkv") + path.unlink(missing_ok=False) + # If only one scene (just using a few frames), should keep same output template. + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time -e 3 {DETECTOR} split-video -m", output_dir=tmp_path + ) + == 0 + ) + path = tmp_path / (Path(DEFAULT_VIDEO_PATH).stem + "-Scene-001.mkv") + path.unlink(missing_ok=False) + # -m takes precedence over -c assert ( invoke_scenedetect( "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c", output_dir=tmp_path ) == 0 ) + # Custom filename format + for scene in range(DEFAULT_NUM_SCENES): + path = tmp_path / (Path(DEFAULT_VIDEO_PATH).stem + f"-Scene-{1 + scene:03d}.mkv") + path.unlink(missing_ok=False) assert ( invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f test$VIDEO_NAME", output_dir=tmp_path, ) == 0 ) + for scene in range(DEFAULT_NUM_SCENES): + path = tmp_path / ("test" + Path(DEFAULT_VIDEO_PATH).stem + f"-{1 + scene:03d}.mkv") + path.unlink(missing_ok=False) # -a/--args and -m/--mkvmerge are mutually exclusive assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -a "-c:v libx264"', output_dir=tmp_path, ) - # TODO: Check for existence of split video files. def test_cli_save_images(tmp_path: Path): @@ -389,17 +606,39 @@ def test_cli_save_images(tmp_path: Path): ) == 0 ) + 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 # Open one of the created images and make sure it has the correct resolution. - # TODO: Also need to test that the right number of images was generated, and compare with - # expected frames from the actual video. - images = glob.glob(os.path.join(tmp_path, "*.jpg")) - assert images - image = cv2.imread(images[0]) + image = cv2.imread(str(images[0])) + assert image is not None + assert image.shape == (544, 1280, 3) + + +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 {}".format( + "電腦檔案-$SCENE_NUMBER-$IMAGE_NUMBER" + ), + output_dir=tmp_path, + ) + == 0 + ) + 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 + # 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) -# TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. -def test_cli_save_images_rotation(rotated_video_file, tmp_path): +# TODO(https://scenedetect.com/issues/134): This works fine with OpenCV currently, but needs to be +# supported for PyAV and MoviePy. +def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): """Test that `save-images` command rotates images correctly with the default backend.""" assert ( invoke_scenedetect( @@ -409,25 +648,140 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path): ) == 0 ) - images = glob.glob(os.path.join(tmp_path, "*.jpg")) - assert images - image = cv2.imread(images[0]) + 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(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) -def test_cli_export_html(tmp_path: Path): - """Test `export-html` command.""" +def test_cli_save_html(tmp_path: Path): + """Test `save-html` command.""" base_command = "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}" + assert invoke_scenedetect(base_command, COMMAND="save-html", output_dir=tmp_path) == 0 + assert ( + 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="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 = """ +0 I -1 +90 I -1 +""" + for filename in (None, "custom.txt"): + filename_format = f"--filename {filename}" if filename else "" + assert ( + invoke_scenedetect( + f"-i {{VIDEO}} time -e 95 {{DETECTOR}} save-qp {filename_format}", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(filename if filename else f"{DEFAULT_VIDEO_NAME}.qp") + assert os.path.exists(output_path) + assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:] + + +def test_cli_save_qp_start_offset(tmp_path: Path): + """Test `save-qp` command but using a shifted start time.""" + # The QP file should always start from frame 0, so we expect a similar result to the above, but + # with the frame numbers shifted by the start frame. Note that on the command-line, the first + # frame is frame 1, but the first frame in a QP file is indexed by 0. + # + # Since we are starting at frame 51, we must shift all cuts by 50 frames. + EXPECTED_QP_CONTENTS = """ +0 I -1 +40 I -1 +""" assert ( - invoke_scenedetect(base_command, COMMAND="save-images export-html", output_dir=tmp_path) + invoke_scenedetect( + "-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp", + output_dir=tmp_path, + ) == 0 ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.qp") + assert os.path.exists(output_path) + assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:] + + +def test_cli_save_qp_no_shift(tmp_path: Path): + """Test `save-qp` command with start time shifting disabled.""" + EXPECTED_QP_CONTENTS = """ +50 I -1 +90 I -1 +""" assert ( - invoke_scenedetect(base_command, COMMAND="export-html --no-images", output_dir=tmp_path) + invoke_scenedetect( + "-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp --disable-shift", + output_dir=tmp_path, + ) == 0 ) - # TODO: Check for existence of HTML & image files. + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.qp") + assert os.path.exists(output_path) + assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:] @pytest.mark.parametrize("backend_type", ALL_BACKENDS) @@ -447,7 +801,7 @@ def test_cli_backend_unsupported(): ) -def test_cli_load_scenes(): +def test_cli_load_scenes_options(): """Ensure we can load scenes both with and without the cut row.""" assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes") == 0 assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 @@ -465,7 +819,7 @@ def test_cli_load_scenes(): assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 -def test_cli_load_scenes_with_time_frames(): +def test_cli_load_scenes_output(): """Verify we can use `load-scenes` with the `time` command and get the desired output.""" scenes_csv = """ Scene Number,Start Frame @@ -476,8 +830,8 @@ def test_cli_load_scenes_with_time_frames(): 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", @@ -508,7 +862,7 @@ def test_cli_load_scenes_with_time_frames(): def test_cli_load_scenes_round_trip(): - """Verify we can use `load-scenes` with the `time` command and get the desired output.""" + """Verify we can use `load-scenes` and get the same scenes as output with `list-scenes`.""" scenes_csv = """ Scene Number,Start Frame 1,49 @@ -518,8 +872,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", @@ -535,8 +889,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", @@ -557,3 +911,525 @@ def test_cli_load_scenes_round_trip(): assert ground_truth.split(SPLIT_POINT)[1] == loaded_first_pass.split(SPLIT_POINT)[1] with open("testout.csv") as first, open("testout2.csv") as second: assert first.readlines() == second.readlines() + + +def test_cli_save_edl(tmp_path: Path): + """Test `save-edl` command.""" + 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__} +TITLE: {DEFAULT_VIDEO_NAME} +FCM: NON-DROP FRAME + +001 AX V C 00:00:02:00 00:00:03:18 00:00:02:00 00:00:03:18 +002 AX V C 00:00:03:18 00:00:06:00 00:00:03:18 00:00:06:00 +""" + assert output_path.read_text() == EXPECTED_EDL_OUTPUT + + +def test_cli_save_edl_with_params(tmp_path: Path): + """Test `save-edl` command but override the other options.""" + 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__} +TITLE: title +FCM: NON-DROP FRAME + +001 BX V C 00:00:02:00 00:00:03:18 00:00:02:00 00:00:03:18 +002 BX V C 00:00:03:18 00:00:06:00 00:00:03:18 00:00:06:00 +""" + assert output_path.read_text() == EXPECTED_EDL_OUTPUT + + +def test_cli_save_otio(tmp_path: Path): + """Test `save-otio` command.""" + 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 = """{ + "OTIO_SCHEMA": "Timeline.1", + "name": "goldeneye (PySceneDetect)", + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": "Video 1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 42.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 48.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 54.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 90.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "name": "Audio 1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 42.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 48.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 54.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 90.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Audio" + } + ] + } +} +""" + assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace( + "{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\") + ) + + +def test_cli_save_otio_no_audio(tmp_path: Path): + """Test `save-otio` command without audio.""" + 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 = """{ + "OTIO_SCHEMA": "Timeline.1", + "name": "goldeneye (PySceneDetect)", + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": "Video 1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 42.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 48.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 54.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 90.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + } + ] + } +} +""" + 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 109872be..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,19 +31,21 @@ 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(#53): Add a test that verifies algorithms output relatively consistent frame scores -# regardless of resolution. This will ensure that threshold values will hold true for different -# input sources. Most detectors already provide this guarantee, so this is more to prevent any -# regressions in the future. +# TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively +# consistent frame scores regardless of resolution. This will ensure that threshold values will hold +# true for different input sources. Most detectors already provide this guarantee, so this is more +# to prevent any regressions in the future. # TODO: Reduce code duplication here and in `conftest.py` @@ -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", ), @@ -187,7 +196,7 @@ def get_fade_in_out_test_cases(): @pytest.mark.parametrize("test_case", get_fast_cut_test_cases()) def test_detect_fast_cuts(test_case: TestCase): scene_list = test_case.detect() - start_frames = [timecode.get_frames() for timecode, _ in scene_list] + start_frames = [timecode.frame_num for timecode, _ in scene_list] assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time @@ -197,7 +206,7 @@ def test_detect_fast_cuts(test_case: TestCase): @pytest.mark.parametrize("test_case", get_fade_in_out_test_cases()) def test_detect_fades(test_case: TestCase): scene_list = test_case.detect() - start_frames = [timecode.get_frames() for timecode, _ in scene_list] + start_frames = [timecode.frame_num for timecode, _ in scene_list] assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time assert scene_list[-1][1] == test_case.end_time @@ -224,3 +233,31 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len + + +@pytest.mark.parametrize("detector_type", FAST_CUT_DETECTORS) +@pytest.mark.parametrize( + "min_scene_len", + # 30 frames at goldeneye.mp4's 24000/1001 (~23.976) fps is ~1.2513s. All four forms should + # produce identical cut lists, demonstrating that detectors accept temporal as well as + # frame-count values. + [30, 1.25, "1.25s", "00:00:01.250"], +) +def test_min_scene_len_accepts_time_values(detector_type, min_scene_len): + """Detectors accept min_scene_len as int (frames), float (seconds), or str (timecode).""" + test_case = TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=detector_type(min_scene_len=min_scene_len), + start_time=1199, + end_time=1450, + # 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] + ), + ) + 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_frame_timecode.py b/tests/test_frame_timecode.py deleted file mode 100644 index 39b25125..00000000 --- a/tests/test_frame_timecode.py +++ /dev/null @@ -1,295 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""PySceneDetect scenedetect.timecode Tests - -This file includes unit tests for the scenedetect.timecode module (specifically, the -FrameTimecode object, used for representing frame-accurate timestamps and time values). - -These unit tests test the FrameTimecode object with respect to object construction, -testing argument format/limits, operators (addition/subtraction), and conversion -to and from various time formats like integer frame number, float number of seconds, -or string HH:MM:SS[.nnn]. timecode format. -""" - -# Third-Party Library Imports -import pytest - -# Standard Library Imports -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode - - -def test_framerate(): - """Test FrameTimecode constructor argument "fps".""" - # Not passing fps results in TypeError. - with pytest.raises(TypeError): - FrameTimecode() - with pytest.raises(TypeError): - FrameTimecode(timecode=0, fps=None) - with pytest.raises(TypeError): - FrameTimecode(timecode=None, fps=FrameTimecode(timecode=0, fps=None)) - # Test zero FPS/negative. - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=0) - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-1) - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-100) - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=0.0) - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-1000.0) - with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=MAX_FPS_DELTA / 2) - # Test positive framerates. - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 - assert FrameTimecode(timecode=0, fps=10).frame_num == 0 - assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA * 2).frame_num == 0 - assert FrameTimecode(timecode=0, fps=1000).frame_num == 0 - assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 - - -def test_timecode_numeric(): - """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" - with pytest.raises(ValueError): - FrameTimecode(timecode=-1, fps=1) - with pytest.raises(ValueError): - FrameTimecode(timecode=-1.0, fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode=-0.1, fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode=-1.0 / 1000, fps=1.0) - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=1, fps=1).frame_num == 1 - assert FrameTimecode(timecode=0.0, fps=1.0).frame_num == 0 - assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == 1 - - -def test_timecode_string(): - """Test FrameTimecode constructor argument "timecode" with string arguments.""" - # Invalid strings: - with pytest.raises(ValueError): - FrameTimecode(timecode="-1", fps=1) - with pytest.raises(ValueError): - FrameTimecode(timecode="-1.0", fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode="-0.1", fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode="1.9x", fps=1) - with pytest.raises(ValueError): - FrameTimecode(timecode="1x", fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode="1.9.9", fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode="1.0-", fps=1.0) - - # Frame number integer [int->str] ('%d', integer number as string) - assert FrameTimecode(timecode="0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1", fps=1).frame_num == 1 - assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 - - # Seconds format [float->str] ('%f', number as string) - assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1 - assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 - - # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) - assert FrameTimecode(timecode="0s", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1s", fps=1).frame_num == 1 - assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 - - # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) - assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1 - assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 - - assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 - assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 - assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 - assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1 - - assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60 - - assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 - - -def test_get_frames(): - """Test FrameTimecode get_frames() method.""" - assert FrameTimecode(timecode=1, fps=1.0).get_frames(), 1 - assert FrameTimecode(timecode=1000, fps=60.0).get_frames(), 1000 - assert FrameTimecode(timecode=1000000000, fps=29.97).get_frames(), 1000000000 - - assert FrameTimecode(timecode=1.0, fps=1.0).get_frames(), int(1.0 / 1.0) - assert FrameTimecode(timecode=1000.0, fps=60.0).get_frames(), int(1000.0 * 60.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int(1000000000.0 * 29.97) - - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_frames(), 2 - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_frames(), 5 - assert FrameTimecode(timecode="00:00:01", fps=10).get_frames(), 10 - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_frames(), 60 - - -def test_get_seconds(): - """Test FrameTimecode get_seconds() method.""" - assert FrameTimecode(timecode=1, fps=1.0).get_seconds(), pytest.approx(1.0 / 1.0) - assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx(1000 / 60.0) - assert FrameTimecode(timecode=1000000000, fps=29.97).get_seconds(), pytest.approx( - 1000000000 / 29.97 - ) - - assert FrameTimecode(timecode=1.0, fps=1.0).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode=1000.0, fps=60.0).get_seconds(), pytest.approx(1000.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx( - 1000000000.0 - ) - - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_seconds(), pytest.approx(2.0) - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_seconds(), pytest.approx(0.5) - assert FrameTimecode(timecode="00:00:01", fps=10).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_seconds(), pytest.approx(60.0) - - -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" - - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.500" - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" - - -def test_equality(): - """Test FrameTimecode equality (==, __eq__) operator.""" - x = FrameTimecode(timecode=1.0, fps=10.0) - assert x == x - assert x == FrameTimecode(timecode=1.0, fps=10.0) - assert x == FrameTimecode(timecode=1.0, fps=10.0) - assert x != FrameTimecode(timecode=10.0, fps=10.0) - assert x != FrameTimecode(timecode=10.0, fps=10.0) - # Comparing FrameTimecodes with different framerates raises a TypeError. - with pytest.raises(TypeError): - assert x == FrameTimecode(timecode=1.0, fps=100.0) - with pytest.raises(TypeError): - assert x == FrameTimecode(timecode=1.0, fps=10.1) - - assert x == FrameTimecode(x) - assert x == FrameTimecode(1.0, x) - assert x == FrameTimecode(10, x) - assert x == "00:00:01" - assert x == "00:00:01.0" - assert x == "00:00:01.00" - assert x == "00:00:01.000" - assert x == "00:00:01.0000" - assert x == "00:00:01.00000" - assert x == 10 - assert x == 1.0 - - with pytest.raises(ValueError): - assert x == "0x" - with pytest.raises(ValueError): - assert x == "x00:00:00.000" - with pytest.raises(TypeError): - assert x == [0] - with pytest.raises(TypeError): - assert x == (0,) - with pytest.raises(TypeError): - assert x == [0, 1, 2, 3] - with pytest.raises(TypeError): - assert x == {0: 0} - - assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.501" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.502" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.508" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.509" - assert FrameTimecode(timecode="00:00:01.519", fps=10) == "00:00:01.510" - - -def test_addition(): - """Test FrameTimecode addition (+/+=, __add__/__iadd__) operator.""" - x = FrameTimecode(timecode=1.0, fps=10.0) - assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) - assert x + 1 == FrameTimecode(1.1, x) - assert x + 10 == 20 - assert x + 10 == 2.0 - - assert x + 10 == "00:00:02.000" - - with pytest.raises(TypeError): - assert FrameTimecode("00:00:02.000", fps=20.0) == x + 10 - - -def test_subtraction(): - """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" - x = FrameTimecode(timecode=1.0, fps=10.0) - assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) - assert x - 2 == FrameTimecode(0.8, x) - assert x - 10 == FrameTimecode(0.0, x) - # TODO(v1.0): Allow negative values - assert x - 11 == FrameTimecode(0.0, x) - assert x - 100 == FrameTimecode(0.0, x) - - assert x - 1.0 == FrameTimecode(0.0, x) - assert x - 100.0 == FrameTimecode(0.0, x) - - assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) - - with pytest.raises(TypeError): - assert FrameTimecode("00:00:02.000", fps=20.0) == x - 10 - - -@pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) -def test_identity(frame_num, fps): - """Test FrameTimecode values, when used in init return the same values""" - frame_time_code = FrameTimecode(frame_num, fps=fps) - assert FrameTimecode(frame_time_code) == frame_time_code - assert FrameTimecode(frame_time_code.get_frames(), fps=fps) == frame_time_code - assert FrameTimecode(frame_time_code.get_seconds(), fps=fps) == frame_time_code - assert FrameTimecode(frame_time_code.get_timecode(), fps=fps) == frame_time_code - - -def test_precision(): - """Test rounding and precision, which has implications for rounding behavior.""" - - fps = 1000.0 - - assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.11" - assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.11" - assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) == "00:00:00.1" - assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.1" - assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) == "00:00:00" - assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" - - assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.99" - assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.99" - assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) == "00:00:01.0" - assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" - assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" - assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..8d1b4d9f --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,517 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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 + +from scenedetect import ( + ContentDetector, + FrameTimecode, + SceneManager, + VideoStreamCv2, + open_video, + save_images, +) +from scenedetect.output import ( + SceneMetadata, + 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 = ( + "-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency" +) +"""Only encodes a small crop of the frame and tuned for performance to speed up tests.""" + + +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): + video = open_video(test_movie_clip) + # Extract three hard-coded scenes for testing, each 30 frames. + scenes = [ + (video.base_timecode + 30, video.base_timecode + 60), + (video.base_timecode + 60, video.base_timecode + 90), + (video.base_timecode + 90, video.base_timecode + 120), + ] + assert ( + split_video_ffmpeg(test_movie_clip, scenes, output_dir=tmp_path, arg_override=FFMPEG_ARGS) + == 0 + ) + # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. + video_name = Path(test_movie_clip).stem + entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) + assert len(entries) == len(scenes) + + +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): + video = open_video(test_movie_clip) + # Extract three hard-coded scenes for testing, each 30 frames. + scenes = [ + (video.base_timecode + 30, video.base_timecode + 60), + (video.base_timecode + 60, video.base_timecode + 90), + (video.base_timecode + 90, video.base_timecode + 120), + ] + + # Custom filename formatter: + def name_formatter(video: VideoMetadata, scene: SceneMetadata): + return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" + + assert ( + split_video_ffmpeg( + test_movie_clip, + scenes, + output_dir=tmp_path, + arg_override=FFMPEG_ARGS, + formatter=name_formatter, + ) + == 0 + ) + video_name = Path(test_movie_clip).stem + entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) + assert len(entries) == len(scenes) + + +# TODO: Add tests for `split_video_mkvmerge`. + + +def test_save_images(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images function.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" + ) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=False, + ) + + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +def test_save_images_singlethreaded(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images function.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" + ) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=True, + ) + + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + 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.""" + video = VideoStreamCv2(test_video_file) + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)] + ] + NUM_IMAGES = 10 + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=10, + image_extension="jpg", + image_name_template=image_name_template, + ) + assert len(image_filenames) == 3 + assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +# +# Scene-list export API (EDL / FCPXML / FCP7 xmeml / OTIO) +# +# These tests construct small synthetic scene lists so they do not require video +# decoding and stay fast. They assert the structural invariants each format must +# hold (e.g. rational time strings for FCPXML, `file://` URIs for xmeml, OTIO +# Clip.2 count matching scene count). + +_FPS_NTSC = Fraction(24000, 1001) +_FPS_CFR = Fraction(30, 1) + + +def _fake_scenes(fps: Fraction, frames): + return [(FrameTimecode(start, fps=fps), FrameTimecode(end, fps=fps)) for start, end in frames] + + +def test_write_scene_list_edl(tmp_path: Path): + """EDL output has title header, FCM line, and one event per scene in CMX 3600 format.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, title="my-clip", reel="AX") + + content = output_path.read_text() + assert "TITLE: my-clip" in content + assert "FCM: NON-DROP FRAME" in content + assert "001 AX V C 00:00:00:00 00:00:01:00 00:00:00:00 00:00:01:00" in content + assert "002 AX V C 00:00:01:00 00:00:02:00 00:00:01:00 00:00:02:00" in content + + +def test_write_scene_list_edl_accepts_str_path(tmp_path: Path): + """`output_path` must accept both Path and str.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(str(output_path), scenes) + assert output_path.exists() + + +def test_write_scene_list_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, + ) + 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 16683bce..b5388e7a 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -15,15 +15,12 @@ which applies SceneDetector algorithms on VideoStream backends. """ -import glob -import os -import os.path -from typing import List +import pytest from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.common import FrameTimecode from scenedetect.detectors import AdaptiveDetector, ContentDetector -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.scene_manager import SceneManager, save_images +from scenedetect.scene_manager import SceneManager, expand_scenes_to_bounds TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] @@ -38,14 +35,14 @@ def test_scene_list(test_video_file): start_time = FrameTimecode("00:00:05", video_fps) end_time = FrameTimecode("00:00:10", video_fps) - assert end_time.get_frames() > start_time.get_frames() + assert end_time.frame_num > start_time.frame_num video.seek(start_time) sm.auto_downscale = True num_frames = sm.detect_scenes(video=video, end_time=end_time) - assert num_frames == (end_time.get_frames() - start_time.get_frames()) + assert num_frames == (end_time.frame_num - start_time.frame_num) scene_list = sm.get_scene_list() assert scene_list @@ -57,7 +54,7 @@ def test_scene_list(test_video_file): assert scene_list[-1][1] == end_time for i, _ in enumerate(scene_list): - assert scene_list[i][0].get_frames() < scene_list[i][1].get_frames() + assert scene_list[i][0].frame_num < scene_list[i][1].frame_num if i > 0: # Ensure frame list is sorted (i.e. end time frame of # one scene is equal to the start time of the next). @@ -84,88 +81,13 @@ def test_get_scene_list_start_in_scene(test_video_file): assert scene_list[0][1] == end_time -def test_save_images(test_video_file): - """Test scenedetect.scene_manager.save_images function.""" - video = VideoStreamCv2(test_video_file) - sm = SceneManager() - sm.add_detector(ContentDetector()) - - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = ( - "scenedetect.tempfile." - "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." - "$TIMESTAMP_MS.$TIMECODE" - ) - - try: - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)] - ] - - image_filenames = save_images( - scene_list=scene_list, - video=video, - num_images=3, - image_extension="jpg", - image_name_template=image_name_template, - ) - - # Ensure images got created, and the proper number got created. - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert os.path.exists(path) - total_images += 1 - - assert total_images == len(glob.glob(image_name_glob)) - - finally: - for path in glob.glob(image_name_glob): - os.remove(path) - - -# TODO: Test other functionality against zero width scenes. -def test_save_images_zero_width_scene(test_video_file): - """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" - video = VideoStreamCv2(test_video_file) - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" - try: - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)] - ] - NUM_IMAGES = 10 - image_filenames = save_images( - scene_list=scene_list, - video=video, - num_images=10, - image_extension="jpg", - image_name_template=image_name_template, - ) - assert len(image_filenames) == 3 - assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert os.path.exists(path) - total_images += 1 - assert total_images == len(glob.glob(image_name_glob)) - finally: - for path in glob.glob(image_name_glob): - os.remove(path) - - # TODO: This would be more readable if the callbacks were defined within the test case, e.g. # split up the callback function and callback lambda test cases. class FakeCallback: """Fake callback used for testing. Tracks the frame numbers the callback was invoked with.""" def __init__(self): - self.scene_list: List[int] = [] + self.scene_list: list[int] = [] def get_callback_lambda(self): """For testing using a lambda..""" @@ -255,3 +177,87 @@ def test_detect_scenes_callback_adaptive(test_video_file): scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] + + +def test_detect_scenes_crop(test_video_file): + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.crop = (10, 10, 1900, 1000) + sm.add_detector(ContentDetector()) + + video_fps = video.frame_rate + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) + video.seek(start_time) + sm.auto_downscale = True + + _ = sm.detect_scenes(video=video, end_time=end_time) + scene_list = sm.get_scene_list() + assert [start for start, _ in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL + + +def test_crop_invalid(): + sm = SceneManager() + 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 # type: ignore[assignment] + with pytest.raises(TypeError): + sm.crop = (1, 1) # type: ignore[assignment] + with pytest.raises(TypeError): + 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 6d47d748..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,15 +27,13 @@ """ import csv -import os -import random from pathlib import Path import pytest from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.common import FrameTimecode from scenedetect.detectors import ContentDetector -from scenedetect.frame_timecode import FrameTimecode from scenedetect.scene_manager import SceneManager from scenedetect.stats_manager import ( COLUMN_NAME_FRAME_NUMBER, diff --git a/tests/test_timecode.py b/tests/test_timecode.py new file mode 100644 index 00000000..3ea8fbd3 --- /dev/null +++ b/tests/test_timecode.py @@ -0,0 +1,557 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""PySceneDetect scenedetect.timecode Tests + +This file includes unit tests for the scenedetect.timecode module (specifically, the +FrameTimecode object, used for representing frame-accurate timestamps and time values). + +These unit tests test the FrameTimecode object with respect to object construction, +testing argument format/limits, operators (addition/subtraction), and conversion +to and from various time formats like integer frame number, float number of seconds, +or string HH:MM:SS[.nnn]. timecode format. +""" + +# Third-Party Library Imports +from fractions import Fraction + +import pytest + +# Standard Library Imports +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() # type: ignore[call-arg] + with pytest.raises(TypeError): + FrameTimecode(timecode=0, fps=None) + with pytest.raises(TypeError): + 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) + with pytest.raises(ValueError): + FrameTimecode(timecode=0, fps=-1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=0, fps=-100.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=0, fps=0.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=0, fps=-1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=0, fps=-1000.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=0, fps=MAX_FPS_DELTA / 2) + # Test positive framerates. + assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=10.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA * 2).frame_num == 0 + assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 + # Reject framerates too small for equality testing or potential divide by zero situations. + with pytest.raises(ValueError): + assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 + + +def test_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) + assert tc.framerate == 30.0 + assert isinstance(tc.framerate, float) + # Constructed directly from a Fraction (the exact form for NTSC rates). + tc = FrameTimecode(timecode=0, fps=Fraction(30000, 1001)) + assert tc.frame_rate == Fraction(30000, 1001) + assert tc.framerate == pytest.approx(float(Fraction(30000, 1001))) + 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 soft-deprecated alias for `equal_frame_rate()` (issue #548). + Both forms should produce identical results for every accepted operand type.""" + tc = FrameTimecode(timecode=0, fps=30.0) + # float, Fraction, FrameTimecode operands. + other_tc = FrameTimecode(timecode=0, fps=30.0) + for other in (30.0, Fraction(30, 1), other_tc): + assert tc.equal_frame_rate(other) == tc.equal_framerate(other) + assert tc.equal_frame_rate(other) is True + # Mismatched rate. + assert tc.equal_frame_rate(24.0) is False + assert tc.equal_framerate(24.0) is False + + +def test_timecode_numeric(): + """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" + with pytest.raises(ValueError): + FrameTimecode(timecode=-1, fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=-1.0, fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=-0.1, fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode=-1.0 / 1000, fps=1.0) + assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1 + assert FrameTimecode(timecode=0.0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == 1 + + +def test_timecode_string(): + """Test FrameTimecode constructor argument "timecode" with string arguments.""" + # Invalid strings: + with pytest.raises(ValueError): + FrameTimecode(timecode="-1", fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode="-1.0", fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode="-0.1", fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode="1.9x", fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode="1x", fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode="1.9.9", fps=1.0) + with pytest.raises(ValueError): + FrameTimecode(timecode="1.0-", fps=1.0) + + # Frame number integer [int->str] ('%d', integer number as string) + assert FrameTimecode(timecode="0", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 + + # Seconds format [float->str] ('%f', number as string) + assert FrameTimecode(timecode="0.0", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1.0", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 + + # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) + assert FrameTimecode(timecode="0s", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1s", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 + + # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) + assert FrameTimecode(timecode="00:00:01", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="00:00:01.9999", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0001", fps=1.0).frame_num == 2 + + # MM:SS[.nnn] is also allowed + assert FrameTimecode(timecode="00:01", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="00:01.9999", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:02.0001", fps=1.0).frame_num == 2 + + # Conversion edge cases + assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10 + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5 + assert FrameTimecode(timecode="00:00:00.100", fps=10.0).frame_num == 1 + assert FrameTimecode(timecode="00:00:00.001", fps=1000.0).frame_num == 1 + + assert FrameTimecode(timecode="00:00:59.999", fps=1.0).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.001", fps=1.0).frame_num == 60 + + assert FrameTimecode(timecode="00:59:59.999", fps=1.0).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.000", fps=1.0).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.001", fps=1.0).frame_num == 3600 + + # Check too many ":" characters (https://github.com/Breakthrough/PySceneDetect/issues/476) + with pytest.raises(ValueError): + FrameTimecode(timecode="01:01:00:00.001", fps=1.0) + + +def test_get_frames(): + """Test FrameTimecode get_frames() method.""" + assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1 + assert FrameTimecode(timecode=1000, fps=60.0).frame_num == 1000 + assert FrameTimecode(timecode=1000000000, fps=29.97).frame_num == 1000000000 + + 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) + # 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 + assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10 + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60 + + +def test_get_seconds(): + """Test FrameTimecode get_seconds() method.""" + assert FrameTimecode(timecode=1, fps=1.0).seconds, pytest.approx(1.0 / 1.0) + assert FrameTimecode(timecode=1000, fps=60.0).seconds, pytest.approx(1000 / 60.0) + assert FrameTimecode(timecode=1000000000, fps=29.97).seconds, pytest.approx(1000000000 / 29.97) + + assert FrameTimecode(timecode=1.0, fps=1.0).seconds, pytest.approx(1.0) + assert FrameTimecode(timecode=1000.0, fps=60.0).seconds, pytest.approx(1000.0) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).seconds, pytest.approx(1000000000.0) + + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).seconds, pytest.approx(2.0) + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).seconds, pytest.approx(0.5) + assert FrameTimecode(timecode="00:00:01", fps=10.0).seconds, pytest.approx(1.0) + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).seconds, pytest.approx(60.0) + + +def test_get_timecode(): + """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" + # 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" + # If a value is provided in seconds, we store that value internally now. + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=False) + == "00:00:01.501" + ) + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=True) + == "00:00:01.500" + ) + + +def test_equality(): + """Test FrameTimecode equality (==, __eq__) operator.""" + x = FrameTimecode(timecode=1.0, fps=10.0) + assert x == x + assert x == FrameTimecode(timecode=1.0, fps=10.0) + assert x == FrameTimecode(timecode=1.0, fps=10.0) + assert x == FrameTimecode(timecode=1.0, fps=Fraction(10, 1)) + assert x != FrameTimecode(timecode=10.0, fps=10.0) + assert x != FrameTimecode(timecode=10.0, fps=10.0) + assert x != FrameTimecode(timecode=10.0, fps=Fraction(100, 10)) + assert x == FrameTimecode(x) + assert x == FrameTimecode(1.0, x) + assert x == FrameTimecode(10, x) + assert x == "00:00:01" + assert x == "00:00:01.0" + assert x == "00:00:01.00" + assert x == "00:00:01.000" + assert x == "00:00:01.0000" + assert x == "00:00:01.00000" + assert x == 10 + assert x == 1.0 + + with pytest.raises(ValueError): + assert x == "0x" + with pytest.raises(ValueError): + assert x == "x00:00:00.000" + with pytest.raises(TypeError): + assert x == [0] + with pytest.raises(TypeError): + assert x == (0,) + with pytest.raises(TypeError): + assert x == [0, 1, 2, 3] + with pytest.raises(TypeError): + assert x == {0: 0} + + assert FrameTimecode(timecode="00:00:00.5", fps=10.0) == "00:00:00.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10.0) == "00:00:01.500" + + +def test_addition(): + """Test FrameTimecode addition (+/+=, __add__/__iadd__) operator.""" + x = FrameTimecode(timecode=1.0, fps=10.0) + assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) + assert x + 1 == FrameTimecode(1.1, x) + assert x + 10 == "00:00:02.000", str(x + 10) + assert x + 10 == 20 + assert x + 10 == 2.0 + assert x + 10 == "00:00:02.000" + + +def test_subtraction(): + """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" + x = FrameTimecode(timecode=1.0, fps=10.0) + assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) + assert x - 2 == FrameTimecode(0.8, x) + assert x - 10 == FrameTimecode(0.0, x) + # TODO(v1.0): Allow negative values. For now we clamp. + assert x - 11 == FrameTimecode(0.0, x) + assert x - 100 == FrameTimecode(0.0, x) + assert x - 1.0 == FrameTimecode(0.0, x) + assert x - 100.0 == FrameTimecode(0.0, x) + assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) + assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10 + + +@pytest.mark.parametrize( + "frame_num,fps", [(1, 1.0), (61, 14.0), (29, 25.0), (126, Fraction(24000, 1001))] +) +def test_identity(frame_num, fps): + """Test FrameTimecode values, when used in init return the same values""" + frame_time_code = FrameTimecode(frame_num, fps=fps) + assert FrameTimecode(frame_time_code) == frame_time_code + assert FrameTimecode(frame_time_code.frame_num, fps=fps) == frame_time_code + assert FrameTimecode(frame_time_code.seconds, fps=fps) == frame_time_code + assert FrameTimecode(frame_time_code.get_timecode(), fps=fps) == frame_time_code + + +def test_precision(): + """Test rounding and precision, which has implications for rounding behavior.""" + + fps = 1000.0 + + assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.11" + assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.11" + assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) == "00:00:00.1" + assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.1" + assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) == "00:00:00" + assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + + assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.99" + assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.99" + assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) == "00:00:01.0" + assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" + assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" + assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + + +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)), + ) + + # ContentDetector: same. + ContentDetector(min_scene_len=FrameTimecode(timecode=15, fps=30.0)) + ContentDetector(min_scene_len=Timecode(pts=500, time_base=Fraction(1, 1000))) 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_splitter.py b/tests/test_video_splitter.py deleted file mode 100644 index d0c4c9c0..00000000 --- a/tests/test_video_splitter.py +++ /dev/null @@ -1,80 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""Tests for scenedetect.video_splitter module.""" - -from pathlib import Path - -import pytest - -from scenedetect import open_video -from scenedetect.video_splitter import ( - SceneMetadata, - VideoMetadata, - is_ffmpeg_available, - split_video_ffmpeg, -) - -FFMPEG_ARGS = ( - "-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency" -) -"""Only encodes a small crop of the frame and tuned for performance to speed up tests.""" - - -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") -def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): - video = open_video(test_movie_clip) - # Extract three hard-coded scenes for testing, each 30 frames. - scenes = [ - (video.base_timecode + 30, video.base_timecode + 60), - (video.base_timecode + 60, video.base_timecode + 90), - (video.base_timecode + 90, video.base_timecode + 120), - ] - assert ( - split_video_ffmpeg(test_movie_clip, scenes, output_dir=tmp_path, arg_override=FFMPEG_ARGS) - == 0 - ) - # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. - video_name = Path(test_movie_clip).stem - entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) - assert len(entries) == len(scenes) - - -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") -def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): - video = open_video(test_movie_clip) - # Extract three hard-coded scenes for testing, each 30 frames. - scenes = [ - (video.base_timecode + 30, video.base_timecode + 60), - (video.base_timecode + 60, video.base_timecode + 90), - (video.base_timecode + 90, video.base_timecode + 120), - ] - - # Custom filename formatter: - def name_formatter(video: VideoMetadata, scene: SceneMetadata): - return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" - - assert ( - split_video_ffmpeg( - test_movie_clip, - scenes, - output_dir=tmp_path, - arg_override=FFMPEG_ARGS, - formatter=name_formatter, - ) - == 0 - ) - video_name = Path(test_movie_clip).stem - entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) - assert len(entries) == len(scenes) - - -# TODO: Add tests for `split_video_mkvmerge`. diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 1d2074b0..d7e90336 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. # @@ -17,15 +17,14 @@ """ import os.path +import typing as ty from dataclasses import dataclass -from typing import List, Type import numpy import pytest from scenedetect.backends import VideoStreamAv, VideoStreamMoviePy from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.video_manager import VideoManager from scenedetect.video_stream import SeekError, VideoStream # Accuracy a framerate is checked to for testing purposes. @@ -42,6 +41,12 @@ MOVIEPY_WARNING_FILTER = "ignore:.*Using the last valid frame instead.:UserWarning" +def get_moviepy_major_version() -> int: + import importlib.metadata + + return int(importlib.metadata.version("moviepy").split(".")[0]) + + def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: if roi: raise RuntimeError("TODO") @@ -60,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 @@ -86,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() -> List[VideoParameters]: +def get_test_video_params() -> list[VideoParameters]: """Fixture for parameters of all videos.""" return [ VideoParameters( @@ -116,21 +120,17 @@ def get_test_video_params() -> 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, - VideoManager, - ], - ) - ), - ), + pytest.mark.parametrize("vs_type", _VS_TYPES), pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), ] @@ -139,12 +139,15 @@ def get_test_video_params() -> List[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_properties( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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.get_frames() == test_video.total_frames + 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(".") assert stream.name == file_name[:last_dot_pos] @@ -152,34 +155,30 @@ def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameter test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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_advance(self, vs_type: Type[VideoStream], test_video: VideoParameters): - """Validate invoking `read` with `advance` set to False.""" - stream = vs_type(test_video.path) - frame = stream.read().copy() - assert stream.frame_number == 1 - frame_copy = stream.read(advance=False) - assert stream.frame_number == 1 - assert calculate_frame_delta(frame, frame_copy) == pytest.approx(0.0) - - def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read_no_decode( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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 - stream.read(decode=False, advance=False) - assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_time_invariants( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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 @@ -200,9 +199,11 @@ def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoPara 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_reset( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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() @@ -212,9 +213,11 @@ def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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) @@ -258,9 +261,11 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_start( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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 @@ -291,11 +296,13 @@ def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameter 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: 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: @@ -306,11 +313,11 @@ def test_read_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters) else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate calling `seek()` to offset past end of video.""" - if vs_type == VideoManager: - pytest.skip(reason="VideoManager does not have compliant end-of-video seek behaviour.") - 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). @@ -320,17 +327,18 @@ def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParame return # For those backends that do allow seek offsets past EOF, they should act as though we # seeked to the end of the video (i.e. shouldn't be able to decode any more frames). - assert stream.read(advance=True) is False - assert stream.read(advance=False) is not False + assert stream.read() is False # TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs. if vs_type == VideoStreamAv: assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1) else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_invalid( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, 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) @@ -344,20 +352,61 @@ def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParamet # -def test_invalid_path(vs_type: Type[VideoStream]): +def test_invalid_path(vs_type: ty.Callable[..., VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): _ = vs_type("this_path_should_not_exist.mp4") -def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): - """Test that backend handles video with corrupt frame gracefully with defaults.""" - if vs_type == VideoManager: - pytest.skip(reason="VideoManager does not support handling corrupt videos.") +def test_framerate_legacy_alias(vs_type: ty.Callable[..., VideoStream], auto_close): + """`framerate=` is the soft-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") + 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). + both = auto_close(vs_type(path, frame_rate=30.0, framerate=24.0)) + assert both.frame_rate == canonical.frame_rate - stream = vs_type(corrupt_video_file) - # OpenCV usually fails to read the video at frame 45, so we 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_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. + # See https://github.com/Zulko/moviepy/pull/2253 for a PR that attempts to more gracefully + # handle this case, however even once that is fixed, we will be unable to run this test + # on certain versions of MoviePy. + pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253") + + 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 + + +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 501fc51e..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,13 +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': @@ -26,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' @@ -39,3 +41,6 @@ markdown_extensions: [fenced_code] extra_css: - style.css + +extra_javascript: + - js/helper.js diff --git a/website/overrides/404.html b/website/overrides/404.html new file mode 100644 index 00000000..7b608acb --- /dev/null +++ b/website/overrides/404.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block content %} +

Page Not Found

+

This page does not exist.

+ + +{% endblock %} \ No newline at end of file diff --git a/website/overrides/main.html b/website/overrides/main.html index 253cc886..f1413372 100644 --- a/website/overrides/main.html +++ b/website/overrides/main.html @@ -6,10 +6,17 @@ {% else %} {% endif %} -{% if page.is_index %} - 🎥 {{ config.site_name }} -{% else %} - -{% endif %} + {% endblock %} + +{% block extrahead %} + +{% endblock %} diff --git a/website/pages/api.md b/website/pages/api.md index 3b09d398..1456b356 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,33 +36,34 @@ 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/scene_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`: ```python -from scenedetect.scene_detector import SceneDetector +import typing as ty +import numpy as np +from scenedetect import FrameTimecode, SceneDetector class CustomDetector(SceneDetector): """CustomDetector class to implement a scene detection algorithm.""" - def __init__(self): - pass - def process_frame(self, frame_num, frame_img, frame_metrics, scene_list): - """Computes/stores metrics and detects any scene changes. - - Returns: - A list containing 1 or more the frame numbers of any detected scenes. - """ + def process_frame( + self, + timecode: FrameTimecode, + frame_im: np.ndarray, + ) -> ty.List[FrameTimecode]: + # Return a list of timecodes where we found cuts (either on this frame or previously). return [] - def post_process(self, scene_list): - pass + def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + # Called after the last frame has been read to handle pending events. + return [] ``` `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/scene_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 31a472ef..334fb876 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -1,12 +1,227 @@ -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 -### 0.6.4 (June 10, 2024) +### PySceneDetect 0.6.7.1 (September 24, 2025) + +Re-release of the Python package that fixes dependency version pinning. + +### PySceneDetect 0.6.7 (August 24, 2025) + +Minor update to fix issues with importing EDL files into DaVinci Resolve and other editors. + +#### Changelog -#### Release Notes + - [bugfix] Fix `save-edl` end timestamp being too short by 1 frame [#516](https://github.com/Breakthrough/PySceneDetect/issues/516) + - [general] Updates to Windows distributions: + - ffmpeg 7.1 -> 8.0 + + +### PySceneDetect 0.6.6 (March 9, 2025) + +PySceneDetect v0.6.6 introduces new output formats, which improve compatibility with popular video editors (e.g. DaVinci Resolve). + +#### Changelog + + - [feature] New `save-otio` command supports saving scenes in OTIO format [#497](https://github.com/Breakthrough/PySceneDetect/issues/497) + - [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) + - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) + - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) + - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option + - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` + - [bugfix] Ensure auto-rotation is always enabled for `VideoStreamCv2` as workaround for (opencv#26795)[https://github.com/opencv/opencv/issues/26795] + - [general] The `export-html` command is now deprecated, use `save-html` instead + - [general] Updates to Windows distributions: + - av 13.1.0 -> 14.2.0 + - click 8.1.7 -> 8.1.8 + - imageio-ffmpeg 0.5.1 -> 0.6.0 + - moviepy 2.1.1 -> 2.1.2 + - numpy 2.1.3 -> 2.2.3 + - opencv-python 4.10.0.84 -> 4.11.0.86 + - [general] Windows download URLs for standalone ZIP distribution no longer have `portable` suffix + + +### PySceneDetect 0.6.5 (November 24, 2024) + +This release brings crop support, performance improvements to save-images, lots of bugfixes, and improved compatibility with MoviePy 2.0+. + +#### Changelog + + - [feature] Add ability to crop input video before processing [#302](https://github.com/Breakthrough/PySceneDetect/issues/302) [#449](https://github.com/Breakthrough/PySceneDetect/issues/449) + - [cli] Add `--crop` option to `scenedetect` command and config file to crop video frames before scene detection + - [api] Add `crop` property to `SceneManager` to crop video frames before scene detection + - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) + - [feature] Add new `--show` flag to `export-html` command to launch browser after processing [#442](https://github.com/Breakthrough/PySceneDetect/issues/442) + - [improvement] Add new `threading` option to `save-images`/`save_images()` [#456](https://github.com/Breakthrough/PySceneDetect/issues/456) + - Enabled by default, offloads image encoding and disk IO to separate threads + - Improves performance by up to 50% in some cases + - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters + - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it + - [improvement] `save_to_csv` now works with paths from `pathlib` + - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` + - [api] The MoviePy backend now works with MoviePy 2.0+ + - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) + - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) + - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/450) + - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix new detectors not working with `default-detector` config option + - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) + - [general] Updates to Windows distributions: + - The MoviePy backend is now included with Windows distributions + - Python 3.9 -> Python 3.13 + - PyAV 10 -> 13.1.0 + - OpenCV 4.10.0.82 -> 4.10.0.84 + - Ffmpeg 6.0 -> 7.1 + +#### Python Distribution Changes + + * *v0.6.5.1* - Fix compatibility issues with PyAV 14+ [#466](https://github.com/Breakthrough/PySceneDetect/issues/466) + * *v0.6.5.2* - Fix for `AttributeError: module 'cv2' has no attribute 'Mat'` [#468](https://github.com/Breakthrough/PySceneDetect/issues/466) + + +### 0.6.4 (June 10, 2024) 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: @@ -30,17 +245,10 @@ Feedback on the new detection methods and their default values is most welcome. - [bugfix] Fix crash when decoded frames have incorrect resolution and log error instead [#319](https://github.com/Breakthrough/PySceneDetect/issues/319) - [bugfix] Update default ffmpeg stream mapping from `-map 0` to `-map 0:v:0 -map 0:a? -map 0:s?` [#392](https://github.com/Breakthrough/PySceneDetect/issues/392) -#### 0.6.4.1 (TBD) - - - [bugfix] Fix `default-detector` config option not working with new detectors - - [bugfix] Fix SyntaxWarning due to incorrect string escaping in command-line (#400) - ### 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:** @@ -82,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:** @@ -129,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 @@ -179,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. @@ -293,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` @@ -325,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` @@ -368,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 @@ -394,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) @@ -586,8 +780,8 @@ Both the Windows installer and portable distributions now include signed executa Development ========================================================== -## PySceneDetect 0.6.5 (TBD) +## PySceneDetect 0.7.2 (TBD) - - [bugfix] Fix new detectors not working with `default-detector` config option - - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) + - [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 8816dc62..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 ``` @@ -29,13 +29,13 @@ As a concrete example to become familiar with PySceneDetect, let's use the follo [https://www.youtube.com/watch?v=OMgIPnCnlbQ](https://www.youtube.com/watch?v=OMgIPnCnlbQ) -You can [download the clip from here](https://github.com/Breakthrough/PySceneDetect/raw/resources/tests/resources/goldeneye/goldeneye.mp4) (right-click and save the video in your working directory as `goldeneye.mp4`). +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 381881e6..8840e94f 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,11 +4,18 @@ ## 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/) * [v0.6.4](0.6.4/) * [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 4d016e4b..0dd1c64c 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -3,30 +3,53 @@ PySceneDetect is completely free software, and can be downloaded from the links below. See the [license and copyright information](copyright.md) page for details. If you have trouble running PySceneDetect, ensure that you have all the required dependencies listed in the [Dependencies](#dependencies) section below. -PySceneDetect requires at least Python 3.7 or higher. +PySceneDetect requires at least Python 3.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.4

-

  Release Date:  June 10, 2024

-  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 61136faf..ba2d6f4e 100644 --- a/website/pages/features.md +++ b/website/pages/features.md @@ -27,13 +27,18 @@ ## Features - - exports list of scenes to .CSV file and terminal (both timecodes and frame numbers) with `list-scenes` command - exports timecodes in standard format (HH:MM:SS.nnn), comma-separated for easy copy-and-paste into external tools and analysis with spreadsheet software - statistics/analysis mode to export frame-by-frame video metrics via the `-s [FILE]`/`--stats [FILE]` argument (e.g. `--stats metrics.csv`) - output-suppression (quiet) mode for better automation with external scripts/programs (`-q`/`--quiet`) - save an image of the first and last frame of each detected scene via the `save-images` command - split the input video automatically if `ffmpeg` or `mkvmerge` is available via the `split-video` command +### Output Formats + + - **EDL**: `save-edl` command (save as edit decision list in CMX 3600 format, compatible with most editors) + - **HTML**: `save-html` command (save HTML table that can be viewed with browser) + - **OTIO**: `save-otio` command (save as [OpenTimelineIO](https://github.com/AcademySoftwareFoundation/OpenTimelineIO) file) + - **QP**: `save-qp` command (can be used with x264 `--qpfile`) ### Detection Methods @@ -45,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. ------------------------------------------------------------------------ @@ -57,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 new file mode 100644 index 00000000..bf8cbf10 Binary files /dev/null and b/website/pages/img/favicon.ico differ diff --git a/website/pages/img/pyscenedetect_logo.png b/website/pages/img/pyscenedetect_logo.png index 66038a07..1b8163e1 100644 Binary files a/website/pages/img/pyscenedetect_logo.png and b/website/pages/img/pyscenedetect_logo.png differ diff --git a/website/pages/img/pyscenedetect_logo_small.png b/website/pages/img/pyscenedetect_logo_small.png index 52145196..0634b34c 100644 Binary files a/website/pages/img/pyscenedetect_logo_small.png and b/website/pages/img/pyscenedetect_logo_small.png 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 2b13d5f7..113ebdf9 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -1,11 +1,10 @@ + PySceneDetect
-

  Latest Release: v0.6.4 (June 10, 2024)

+

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

  Download        Changelog        Documentation        Getting Started -
-See the changelog for the latest release notes and known issues.
**PySceneDetect** is a tool for **detecting shot changes in videos** ([example](cli.md)), and can **automatically split the video into separate clips**. PySceneDetect is free and open-source software, and has several [detection methods](features.md#detection-methods) to find fast-cuts and threshold-based fades. @@ -14,7 +13,7 @@ See the changelog for the latest release notes and known issues. Split video on each fast cut using [command line (more examples)](cli.md): -```rst +```bash scenedetect -i video.mp4 split-video ``` 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 f5ca61cf..8c59567a 100644 --- a/website/pages/style.css +++ b/website/pages/style.css @@ -18,4 +18,82 @@ padding:4px 6px; margin-bottom:.809em; max-width:100% +} + + +#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 cd132c7f..00000000 --- a/website/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -mkdocs==1.5.2 -jinja2==3.1.4