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/blank-template.md b/.github/ISSUE_TEMPLATE/blank-template.md deleted file mode 100644 index 74e86065..00000000 --- a/.github/ISSUE_TEMPLATE/blank-template.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: Blank Template -about: Blank template for any bug report, defect, feature request, enhancement, or - idea. - ---- - - diff --git a/.github/ISSUE_TEMPLATE/bug-or-issue-report.md b/.github/ISSUE_TEMPLATE/bug-or-issue-report.md deleted file mode 100644 index 8a19cabe..00000000 --- a/.github/ISSUE_TEMPLATE/bug-or-issue-report.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Bug or Issue Report -about: Submit a bug report or issue to help us fix and improve PySceneDetect. - ---- - -**Bug/Issue Description:** -A clear and concise description of what the bug or issue is - in other words, what the *unexpected* behavior or output is. - -**Required Information:** -Provide the following information to assist with reporting the bug: -1. Provide a full copy of the command line options you are using, for example: - -`scenedetect -i some_video.mp4 -s some_video.stats.csv -o outdir detect-content --threshold 28 list-scenes save-images` - -2. Add `-v debug -l BUG_REPORT.txt` to the beginning of the command, then re-run PySceneDetect and **attach the generated `BUG_REPORT.txt` file**. - -**Expected Behavior:** -A clear and concise description of what you *expected* to happen. - -**Computing Environment:** - - OS: [e.g. Windows, Linux (Distro: Ubuntu, Mint, Fedora, etc...), OSX] - - Python Version: [e.g. 3.6 or 3.6.6] - - OpenCV Version: [e.g. 3.4.1] - -**Additional Information:** -Add any other information you feel might be relevant to the bug/issue report but was not covered in one of the previous categories. - -**Media [Videos/Images/Screenshots]:** -Provide any other information you can, including videos/media that can demonstrate the bug you are reporting (even YouTube links are fine). If applicable, add the output images from PySceneDetect, or any screenshots you feel are necessary to help explain your problem. - -Remove this section if there is no media associated with the issue/bug report. diff --git a/.github/ISSUE_TEMPLATE/feature-or-enhancement-request.md b/.github/ISSUE_TEMPLATE/feature-or-enhancement-request.md deleted file mode 100644 index 473a6710..00000000 --- a/.github/ISSUE_TEMPLATE/feature-or-enhancement-request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Feature or Enhancement Request -about: Submit an idea. or request for a feature to be added to PySceneDetect. - ---- - -**Description of Problem & Solution** -A clear and concise description of what the problem your feature/idea solves. Ex. PySceneDetect doesn't work well in cases X or Y but if it could detect Z [...], PySceneDetect is slow because of X but could be faster if it did Y [...], or I need PySceneDetect to do X because of some condition Y [...]. - -**Media Examples:** -Where possible, provide videos/images demonstrating the problem at hand or issue you would like to solve (even YouTube links are fine). - -**Proposed Implementation:** -A clear and concise description of what you want to happen, how you think the feature/enhancement should be implemented. Ex. what command line options/arguments should be added to PySceneDetect, and examples of how you expect them to function. - -**Alternative Solutions:** -A clear and concise list of descriptions for any alternative solutions or features you've considered. diff --git a/.github/ISSUE_TEMPLATE/scenedetect_app.md b/.github/ISSUE_TEMPLATE/scenedetect_app.md new file mode 100644 index 00000000..49658a85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/scenedetect_app.md @@ -0,0 +1,27 @@ +--- +name: Application Bug +about: Help us fix and improve PySceneDetect by reporting bugs or other issues. + +--- + +**Description:** + +Describe what the bug or issue is (e.g. crashes when setting X) and how it can be reproduced. + +**Command:** + +Place a full copy of the command line options you are using here, for example: + +`scenedetect -i some_video.mp4 -s some_video.stats.csv -o outdir detect-content --threshold 28 list-scenes save-images` + +**Output:** + +Copy the output of running the application here. Where possible, generate a debug log by adding `-v debug -l BUG_REPORT.txt` to the beginning of your command, and **attach `BUG_REPORT.txt` to your issue**. + +**Environment:** + +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:** + +Attach or link to any files relevant to the issue, including videos (or YouTube links), scene files, stats files, and log output. diff --git a/.github/ISSUE_TEMPLATE/scenedetect_package.md b/.github/ISSUE_TEMPLATE/scenedetect_package.md new file mode 100644 index 00000000..3215afc0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/scenedetect_package.md @@ -0,0 +1,28 @@ +--- +name: Python API +about: Programmers using the `scenedetect` package. + +--- + +**Description:** + +Describe the issue (unexpected result, exception thrown, etc...) and any relevant output. + +**Example:** + +Include code samples that demonstrate the issue: + +```python +from scenedetect import detect, ContentDetector, split_video_ffmpeg + +scene_list = detect("my_video.mp4", ContentDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) +``` + +**Environment:** + +Run `scenedetect version` and include the output. This will describe the environment/OS/platform and versions of dependencies you have installed. + +**Media/Files:** + +Attach or link to any files relevant to the issue, including videos (or YouTube links), scene files, stats files, and log output. diff --git a/.github/ISSUE_TEMPLATE/scenedetect_request.md b/.github/ISSUE_TEMPLATE/scenedetect_request.md new file mode 100644 index 00000000..0de1ff8e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/scenedetect_request.md @@ -0,0 +1,25 @@ +--- +name: Feature or Enhancement +about: Submit an idea or feature request to make PySceneDetect better. + +--- + +**Problem/Use Case** + +Describe what problem you want to solve, or what use case you want to achieve. Ex. PySceneDetect doesn't work well in cases X or Y but if it could detect Z [...], PySceneDetect is slow because of X but could be faster if it did Y [...], or I need PySceneDetect to do X because of some condition Y [...]. + +**Solutions** + +Discuss any potential solutions here. + +**Proposed Implementation:** + +Description of what you want to happen, and how you think the feature/enhancement should be implemented. Ex. what command line options/arguments should be added to PySceneDetect, and examples of how you expect them to function. + +**Alternatives:** + +List any alternative solutions or related ideas you've considered. + +**Examples:** + +Attach or link to any relevant videos or images that are relevant. diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml new file mode 100644 index 00000000..76cee703 --- /dev/null +++ b/.github/actions/setup-ffmpeg/action.yml @@ -0,0 +1,112 @@ +name: 'Setup FFmpeg' +description: 'Ensure ffmpeg is available on the runner, using OS package managers as a fallback.' +inputs: + github-token: + description: 'Unused; kept for backward compatibility with existing callers.' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Set ffmpeg install config + shell: bash + run: | + VERSION=8.1 + echo "FFMPEG_VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "FFMPEG_ASSET=ffmpeg-${VERSION}-essentials_build.zip" >> "$GITHUB_ENV" + + - name: Check for preinstalled ffmpeg + id: check + shell: bash + 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: + 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 new file mode 100644 index 00000000..495d68c3 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,134 @@ +# Build Portable Windows EXE (x64) Distribution for PySceneDetect + +name: Windows Distribution + +on: + schedule: + - cron: '0 0 * * *' + pull_request: + paths: + - packaging/** + - scripts/** + - scenedetect/** + - tests/** + - pyproject.toml + - .github/workflows/build-windows.yml + push: + paths: + - packaging/** + - scripts/** + - scenedetect/** + - tests/** + - pyproject.toml + - .github/workflows/build-windows.yml + branches: + - main + - 'releases/**' + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + strategy: + matrix: + python-version: ["3.13"] + + env: + ffmpeg-version: "8.1" + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + 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 + pip install .[docs] + pip install --upgrade -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg + + - name: Download 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/ + + - name: Download FFMPEG ${{ env.ffmpeg-version }} + uses: dsaltares/fetch-gh-release-asset@1.1.2 + with: + repo: 'GyanD/codexffmpeg' + version: 'tags/${{ env.ffmpeg-version }}' + 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 scripts/pre_release.py + pyinstaller packaging/windows/scenedetect.spec + + - name: Build Documentation + run: | + sphinx-build -b singlehtml docs dist/scenedetect/docs + rm -r dist/scenedetect/docs/.doctrees + + - name: Assemble Portable Distribution + run: | + Move-Item -Path LICENSE -Destination dist/scenedetect/ + New-Item -Path dist/scenedetect/ -Name thirdparty -ItemType Directory + 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 + + - name: Test Portable Distribution + run: | + ./dist/scenedetect/scenedetect -i tests/resources/goldeneye.mp4 detect-content time -e 2s + + - name: Upload Artifact + uses: actions/upload-artifact@v6 + with: + name: PySceneDetect-win64 + path: dist/scenedetect + include-hidden-files: true + + test: + runs-on: windows-latest + needs: build + steps: + - uses: actions/checkout@v5 + with: + ref: resources + + - uses: actions/download-artifact@v7 + with: + name: PySceneDetect-win64 + path: build + + - name: Test + run: | + echo Testing binary + ./build/scenedetect version + 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 new file mode 100644 index 00000000..d6f0f6ef --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,161 @@ +# Test PySceneDetect on Linux/OSX/Windows and generate Python distribution (sdist/wheel). +name: Python Distribution + +on: + schedule: + - cron: '0 0 * * *' + pull_request: + paths: + - packaging/** + - scripts/** + - scenedetect/** + - tests/** + - pyproject.toml + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** + push: + paths: + - packaging/** + - scripts/** + - scenedetect/** + - tests/** + - pyproject.toml + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** + branches: + - main + - 'releases/**' + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + env: + # Version is extracted below and used to find correct package install path. + scenedetect_version: "" + + steps: + - uses: actions/checkout@v5 + + - name: Setup FFmpeg + uses: ./.github/actions/setup-ffmpeg + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python ${{ matrix.python-version }} + 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 + 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: | + 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-core + + - name: Build Package + shell: bash + run: | + # 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 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 + + - 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 venv .smoke-headless + VENV_BIN=.smoke-headless/bin + [ -d .smoke-headless/Scripts ] && VENV_BIN=.smoke-headless/Scripts + source "$VENV_BIN/activate" + pip install "dist/scenedetect_headless-${{ env.scenedetect_version }}-py3-none-any.whl[pyav]" --only-binary av + # Confirm the install pulled `opencv-python-headless`, not the GUI variant. + # If both are present, cv2 imports may silently come from either. + python -c "import importlib.metadata as m; ds = sorted(d.metadata['Name'] for d in m.distributions() if d.metadata['Name'] in ('opencv-python', 'opencv-python-headless')); assert ds == ['opencv-python-headless'], f'Expected only opencv-python-headless, got: {ds}'" + scenedetect version + scenedetect -i tests/resources/testvideo.mp4 -b opencv time --end 2s + scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s + + - name: Smoke Test Package (Upgrade From 0.7) + shell: bash + run: | + # Both packages ship the code (no metapackage layering) precisely so that + # in-place upgrades from older installs keep working - a code-carrying dist + # flipped to a code-free metapackage would break here, since uninstalling the old + # version deletes module files a dependency just wrote. This is also why the + # short-lived scenedetect-core (published in 0.7.1 only) was yanked rather than + # layered on. Keep this as a regression guard for that failure mode + # (https://scenedetect.com/issues/558). + python -m venv .smoke-upgrade + VENV_BIN=.smoke-upgrade/bin + [ -d .smoke-upgrade/Scripts ] && VENV_BIN=.smoke-upgrade/Scripts + source "$VENV_BIN/activate" + pip install "scenedetect==0.7" + pip install --upgrade --find-links dist/ "scenedetect==${{ env.scenedetect_version }}" + python -c "import scenedetect; print(scenedetect.__version__)" + scenedetect version + + - name: Upload Package + if: ${{ matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' }} + uses: actions/upload-artifact@v6 + with: + name: scenedetect-dist + path: | + dist/*.tar.gz + dist/*.whl 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 new file mode 100644 index 00000000..9dd65e17 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,52 @@ +# CodeQL for PySceneDetect +name: "CodeQL" + +on: + push: + branches: + - main + - releases/** + paths: + - scenedetect/** + - tests/** + pull_request: + branches: + - main + - releases/* + paths: + - scenedetect/** + - tests/** + schedule: + - cron: "20 7 * * 4" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..046e9c88 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + 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 new file mode 100644 index 00000000..12ccd7a2 --- /dev/null +++ b/.github/workflows/generate-docs.yml @@ -0,0 +1,93 @@ +# Generate PySceneDetect documentation and updates the gh-pages branch. +name: Generate Documentation + +on: + push: + branches: + - main # docs/head + - 'releases/**' # docs/** + paths: + - 'docs/**' + workflow_dispatch: + +jobs: + update_docs: + runs-on: ubuntu-latest + permissions: + contents: write # pushes generated docs to the gh-pages branch + env: + scenedetect_docs_dest: '' + + steps: + - uses: actions/checkout@v5 + + - name: Get Latest Version + run: | + git fetch origin main --depth=1 + echo "scenedetect_docs_latest=$(git show origin/main:docs/LATEST_VERSION | tr -d '[:space:]')" >> "$GITHUB_ENV" + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: 'pip' + + - name: Set Destination (Releases) + if: ${{ contains(github.ref_name, 'releases') }} + run: | + echo "scenedetect_docs_dest=$(echo ${{ github.ref_name }} | cut -b 10-)" >> "$GITHUB_ENV" + + - name: Set Destination (Head) + if: ${{ contains(github.ref_name, 'main') }} + run: | + echo "scenedetect_docs_dest=head" >> "$GITHUB_ENV" + + - name: Check Destination + if: ${{ env.scenedetect_docs_dest == '' }} + run: | + echo "Failing build: destination must be set!" + + - name: Setup Environment + run: | + python -m pip install --upgrade pip build wheel virtualenv + 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 + + - name: Update gh-pages Branch + run: | + git fetch origin gh-pages + git checkout gh-pages + git rm "docs/${{ env.scenedetect_docs_dest }}" -r -f --ignore-unmatch + git add build/ + git mv build "docs/${{ env.scenedetect_docs_dest }}" + + - name: Update Latest + if: ${{ env.scenedetect_docs_dest == env.scenedetect_docs_latest }} + run: | + git rm "docs/latest" -r -f --ignore-unmatch + mkdir -p latest + cp -r -f "docs/${{ env.scenedetect_docs_dest }}" docs/latest + git add docs/latest + echo "scenedetect_docs_dest='${{ env.scenedetect_docs_dest }} (latest)'" >> "$GITHUB_ENV" + + - name: Commit and Push + run: | + git commit -a -m "[docs] @${{ github.triggering_actor }}: Generate Documentation" \ + -m "Source: ${{ github.ref_name }} (${{ github.sha }})" \ + -m "Destination: ${{ env.scenedetect_docs_dest }}" + git push diff --git a/.github/workflows/generate-website.yml b/.github/workflows/generate-website.yml new file mode 100644 index 00000000..6e924106 --- /dev/null +++ b/.github/workflows/generate-website.yml @@ -0,0 +1,53 @@ + +name: Generate Website + +on: + push: + branches: + - main + paths: + - 'website/**' + workflow_dispatch: + +jobs: + update_site: + runs-on: ubuntu-latest + permissions: + contents: write # pushes generated site to the gh-pages branch + + 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 .[website] + + - name: Generate Website + run: | + mkdocs build -f website/mkdocs.yml + + - name: Update Website + run: | + git fetch origin gh-pages + git checkout gh-pages + git rm * -r -f --ignore-unmatch + git checkout HEAD -- .nojekyll + git checkout HEAD -- CNAME + git checkout HEAD -- docs/ + git rm docs/index.html --ignore-unmatch + git add website/build/ + git mv website/build/* . -f -k + git mv website/build/docs/index.html docs/index.html -k + git rm website/build/* -r -f --ignore-unmatch + git config --global user.name github-actions + git config --global user.email github-actions@github.com + git commit -a -m "[docs] @${{ github.triggering_actor }}: Generate Website" \ + -m "Commit: ${{ github.sha }}" + git push 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/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 00000000..f2d432f3 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,47 @@ +# Check PySceneDetect code lint warnings and formatting. +name: Static Analysis + +on: + pull_request: + 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@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 + python -m pip install -e .[dev] --only-binary av,opencv-python + + - name: Static Analysis (ruff) + 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 8e4267e8..b2f656fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,98 @@ -*.pyc -*.pyo -site/ +docs/_build/ +docs/STYLE.md +website/build/ +scripts/local/ +tests/resources/* +*.mp4 +*.jpg +*.jpeg +*.patch +*.exe +*.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 + +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ *.egg-info/ -manual/_build/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +*.mo +*.pot +.scrapy +docs/_build/ +.pybuilder/ +target/ +.ipynb_checkpoints +profile_default/ +ipython_config.py +.pdm.toml +__pypackages__/ +celerybeat-schedule +celerybeat.pid +*.sage.py +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.spyderproject +.spyproject +.ropeproject +/site +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +cython_debug/ +test_clips/ diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..748d4dbf --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,12 @@ +cff-version: 1.2.0 +title: PySceneDetect +message: www.scenedetect.com +type: software +authors: + - given-names: Brandon + family-names: Castellano + affiliation: www.bcastell.com +repository-code: 'https://github.com/Breakthrough/PySceneDetect' +url: 'https://www.scenedetect.com' +abstract: Video Cut Detection and Analysis Tool +license: BSD-3-Clause 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 c213d916..c03985e6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,102 +1,28 @@ -By downloading, copying, installing, or using this software, you agree -to the terms of this license, and those contained in the "Ancillary -Software Licenses" section below. If you do not agree to any of these -terms or licenses, do not download, install, copy, or use the software -or any other material included in in distribution. +BSD 3-Clause License ------------------------------------------------------------------------ - - PySceneDetect License (BSD 3-Clause) - < http://www.bcastell.com/projects/pyscenedetect > - -Copyright (C) 2012-2018, Brandon Castellano. -All rights reserved. +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: - - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials - provided with the distribution. - - 3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ------------------------------------------------------------------------ - - Ancillary Software Licenses - -This software uses the following third-party open source libraries and -are released under the terms detailed below. By downloading, copying, -installing or using this software/tutorial, you agree to these terms. - ------------------------------------------------------------------------ - -> NumPy [Copyright (C) 2005-2016, Numpy Developers]: - This software uses Numpy; see the LICENSE-NUMPY file - or visit [ http://www.numpy.org/license.html ] for details. - -> OpenCV [Copyright (C) 2017, Itseez]: - This software uses OpenCV; see the LICENSE-OPENCV file - or visit [ http://opencv.org/license.html ] for details. - -> click [Copyright (C) 2017, Armin Ronacher]: - This software uses OpenCV; see the LICENSE-CLICK file - or visit [ http://click.pocoo.org/license/ ] for details. - -> tqdm [Copyright (C) 2013-2018, Casper da Costa-Luis, - Google Inc., and Noam Yorav-Raphael]: - This software uses tqdm; see the LICENSE-TQDM file, or visit - the following URL for details: - [ https://raw.githubusercontent.com/tqdm/tqdm/master/LICENCE ] - -> pytest [Copyright (C) 2004-2017, Holger Krekel and others]: - This software uses pytest; see the LICENSE-PYTEST file, or visit - [ https://docs.pytest.org/en/latest/license.html ] 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, or visit the -below URLs for details. In source distributions of PySceneDetect, -neither mkvmerge nor FFmpeg is not distributed, and requires manual -installation in order to allow automatic video splitting capability. -These programs can be obtained from following URLs (note that mkvmerge -is a part of the MKVToolNix package): - - FFmpeg: [ https://ffmpeg.org/download.html ] - mkvmerge: [ https://mkvtoolnix.download/downloads.html ] - -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. - -Additionally, certain Windows distributions may include a compiled -Python distribution. For license information regarding the distributed -version of Python, see the LICENSE files in the installation directory, -or visit the following URL: [ https://docs.python.org/3/license.html ] - diff --git a/MANIFEST.in b/MANIFEST.in index 6b317bae..cf223d6b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,12 @@ -include *.md -include *.rst -include LICENSE* +recursive-exclude .github * +recursive-exclude packaging * +recursive-exclude scripts * +recursive-exclude docs * +recursive-exclude website * +exclude * +include README.md +include LICENSE +include pyproject.toml +include scenedetect.cfg +include packaging/package-info.rst +recursive-include docs * diff --git a/README.md b/README.md index 8115391c..f3508a94 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,146 @@ -![PySceneDetect](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/master/docs/img/pyscenedetect_logo_small.png) -========================================================== -Video Scene Cut Detection and Analysis Tool ----------------------------------------------------------- - -[![Documentation Status](https://readthedocs.org/projects/pyscenedetect/badge/?version=latest)](http://pyscenedetect.readthedocs.org/en/latest/?badge=latest) [![PyPI Status](https://img.shields.io/pypi/status/scenedetect.svg)](https://pypi.python.org/pypi/scenedetect/) [![PyPI Version](https://img.shields.io/pypi/v/scenedetect.svg)](https://pypi.python.org/pypi/scenedetect/) [![PyPI License](https://img.shields.io/pypi/l/scenedetect.svg)](http://pyscenedetect.readthedocs.org/en/latest/copyright/) - - -### Latest Release: v0.5 (August 31, 2018) + + + PySceneDetect + -**Main Webpage**: [py.scenedetect.com](http://py.scenedetect.com) +# Video Cut Detection and Analysis Tool -**Documentation**: [manual.scenedetect.com](http://manual.scenedetect.com) - -**Download/Install**: https://pyscenedetect.readthedocs.io/en/latest/download/ +[![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/) +[![PyPI Version](https://img.shields.io/pypi/v/scenedetect?color=blue)](https://pypi.python.org/pypi/scenedetect/) +[![PyPI License](https://img.shields.io/pypi/l/scenedetect.svg)](https://scenedetect.com/copyright/) ---------------------------------------------------------- -**Quick Install**: Requires Python modules `numpy`, OpenCV `cv2`, and (optional) `tqdm` for displaying progress. To install PySceneDetect via `pip`: - - pip install scenedetect +### Latest Release: v0.7.1 (July 21, 2026) -To test if you have the required prerequisites, open a `python` prompt, and run the following: +**Website**: [scenedetect.com](https://www.scenedetect.com) - import numpy - import cv2 +**Quickstart Example**: [scenedetect.com/cli/](https://www.scenedetect.com/cli/) -If both of those commands execute without any problems, you should be able to install PySceneDetect without any issues. To enable video splitting support, you will also need to have `mkvmerge` or `ffmpeg` installed on your system. See [getting started guide](http://pyscenedetect.readthedocs.org/en/latest/examples/usage/) after installation for details. +**Documentation**: [scenedetect.com/docs/](https://www.scenedetect.com/docs/) -Also see [the `USAGE.md` file](https://github.com/Breakthrough/PySceneDetect/blob/master/USAGE.md) for details on detection modes, default values/thresholds to try, and how to effectively choose the optimal detection parameters. Full documentation for PySceneDetect can be found [on Readthedocs](http://pyscenedetect.readthedocs.org/), or by visiting [py.scenedetect.com](http://py.scenedetect.com/). +**Discord**: https://discord.gg/H83HbJngk7 -To install from source instead, download the latest release and call `python setup.py install` (see [the download page](https://pyscenedetect.readthedocs.io/en/latest/download/) for details. +---------------------------------------------------------- +**Quick Install**: ----------------------------------------------------------- + pip install scenedetect --upgrade -PySceneDetect is a command-line tool, written in Python and using OpenCV, which analyzes a video, looking for scene changes or cuts. The output timecodes can then be used with another tool (e.g. `mkvmerge`, `ffmpeg`) to split the video into individual clips. A frame-by-frame analysis can also be generated for a video, to help with determining optimal threshold values or detecting patterns/other analysis methods for a particular video. See [the `USAGE.md` file](https://github.com/Breakthrough/PySceneDetect/blob/master/USAGE.md) for details. +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). -There are two main detection methods PySceneDetect uses: `detect-threshold` (comparing each frame to a set black level, useful for detecting cuts and fades to/from black), and `detect-content` (compares each frame sequentially looking for changes in content, useful for detecting fast cuts between video scenes, although slower to process). Each mode has slightly different parameters, and is described in detail below. +---------------------------------------------------------- -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-content` mode. Once you know what detection mode to use, you can try the parameters recommended below, or generate a statistics file (using the `-s` / `--statsfile` flag) in order to determine the correct paramters - specifically, the proper threshold value. +**Quick Start (Command Line)**: -Note that PySceneDetect is currently in beta; see Current Features & Roadmap below for details. For help or other issues, you can contact me on [my website](http://www.bcastell.com/about/), or we can chat in #pyscenedetect on Freenode. Feel free to submit any bugs or feature requests to [the Issue Tracker](https://github.com/Breakthrough/PySceneDetect/issues) here on Github. +Split input video on each fast cut using `ffmpeg`: + scenedetect -i video.mp4 split-video -Download & Installation ----------------------------------------------------------- +Save some frames from each cut: -**Downloading:** The latest version of PySceneDetect (`v0.4`) can be [downloaded here](https://github.com/Breakthrough/PySceneDetect/releases); to run it, you will need: + scenedetect -i video.mp4 save-images - - [Python 2 / 3](https://www.python.org/) - - [OpenCV](https://opencv.org/) Python Module (usually found in Linux package repos as `python-opencv`, Windows users can find [prebuilt binaries for Python 2.7 here](http://www.lfd.uci.edu/~gohlke/pythonlibs/#opencv)) - - [Numpy](http://sourceforge.net/projects/numpy/) - - [tqdm](https://github.com/tqdm/tqdm) (optional, can install via `pip install tqdm`) +Skip the first 10 seconds of the input video: -To enable video splitting support, you also need to have one of the following tools installed (Linux users can usually grab them from your package manager): + scenedetect -i video.mp4 time -s 10s - - [ffmpeg](https://ffmpeg.org/download.html) - - [mkvmerge](https://mkvtoolnix.download/downloads.html) (part of mkvtoolnix) +More examples can be found throughout [the documentation](https://www.scenedetect.com/docs/latest/cli.html). -More complete documentation and installation instructions can be [found on Readthedocs](http://pyscenedetect.readthedocs.org/en/latest/download/), including a detailed guide on how to install the above dependencies. Note that in some cases the Windows version may require an additional `opencv_ffmpeg.dll` file for the specific version of OpenCV installed. +**Quick Start (Docker)**: -To ensure you have all the system requirements installed, open a `python` interpreter/REPL, and ensure you can `import numpy` and `import cv2` without any errors. You can download a test video and view the expected output [from the resources branch](https://github.com/Breakthrough/PySceneDetect/tree/resources/tests) (see the end of the Usage section below for details). +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: -**Installing:** Once you have all the system requirements, go to where you [downloaded PySceneDetect](https://github.com/Breakthrough/PySceneDetect/releases) and extract the archive. To install PySceneDetect, run the following command in the folder containing the extracted files (the one containing `setup.py`): + docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect -i /files/video.mp4 split-video -o /files - python setup.py install +**Quick Start (Python API)**: -After installation, you can use PySceneDetect as the `scenedetect` command from any terminal/command prompt. To verify the installation, run the following command to display what version of PySceneDetect you have installed: +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): - scenedetect version +```python +from scenedetect import detect, ContentDetector +scene_list = detect("my_video.mp4", ContentDetector()) +``` -Usage ----------------------------------------------------------- +`scene_list` will now be a list containing the start/end times of all scenes found in the video. There also exists a two-pass version `AdaptiveDetector` which handles fast camera movement better, and `ThresholdDetector` for handling fade out/fade in events. -**There is now a dedicated [`USAGE.md` file (here)](https://github.com/Breakthrough/PySceneDetect/blob/master/USAGE.md) containing more detailed usage instructions. Documentation is also being [added to Readthedocs](http://pyscenedetect.readthedocs.org/), which will eventually replace the content of this file (see the [PySceneDetect Quickstart Section](http://pyscenedetect.readthedocs.org/en/latest/examples/usage/) for details)..** +Try calling `print(scene_list)`, or iterating over each scene: -To run PySceneDetect, use the `scenedetect` command if you have it installed to your system. Otherwise, if you are running from source, you can invoke `python scenedetect.py` or `./scenedetect.py` (instead of `scenedetect` in the examples shown below and elsewhere). To display the help file, detailing the command line parameters: +```python +from scenedetect import detect, ContentDetector - scenedetect help +scene_list = detect("my_video.mp4", ContentDetector()) +for i, scene in enumerate(scene_list): + print( + " Scene %2d: Start %s / Frame %d, End %s / Frame %d" + % ( + i + 1, + scene[0].get_timecode(), + scene[0].frame_num, + scene[1].get_timecode(), + scene[1].frame_num, + ) + ) +``` -To perform content-based analysis with the default parameters, on a video named `myvideo.mp4`, saving a list of scenes to `myvideo_scenes.csv` (they are also printed to the terminal when `list-scenes` is specified): +We can also split the video into each scene if `ffmpeg` is installed (`mkvmerge` is also supported): - scenedetect --input myvideo.mp4 detect-content list-scenes -o myvideo_scenes.csv +```python +from scenedetect import detect, ContentDetector, split_video_ffmpeg -To automatically split the input video into scenes using stream copying (default) with a statsfile specified (requires `ffmpeg` or `mkvmerge` to be installed): +scene_list = detect("my_video.mp4", ContentDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) +``` - scenedetect --input myvideo.mp4 --statsfile myvideo.stats.csv detect-content split-video +For more advanced usage, the API is highly configurable, and can easily integrate with any pipeline. This includes using different detection algorithms, splitting the input video, and much more. The following example shows how to implement a function similar to the above, but using [the `scenedetect` API](https://www.scenedetect.com/docs/latest/api.html): -To automatically split the input video in *precise* mode (re-encodes input, slower but frame-perfect accuracy for output files, requires `ffmpeg` to be installed): +```python +from scenedetect import open_video, SceneManager, split_video_ffmpeg +from scenedetect.detectors import ContentDetector +from scenedetect.video_splitter import split_video_ffmpeg - scenedetect --input myvideo.mp4 --statsfile myvideo.stats.csv detect-content split-video -p -To perform content-based analysis, with a threshold intensity of 30: +def split_video_into_scenes(video_path, threshold=27.0): + # Open our video, create a scene manager, and add a detector. + video = open_video(video_path) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector(threshold=threshold)) + scene_manager.detect_scenes(video, show_progress=True) + scene_list = scene_manager.get_scene_list() + split_video_ffmpeg(video_path, scene_list, show_progress=True) +``` - scenedetect --input myvideo.mp4 detect-content --threshold 30 +See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for more examples. -To perform threshold-based analysis, with a threshold intensity of 16 and a match percent of 90: +**Benchmark**: - scenedetect --input myvideo.mp4 detect-threshold --threshold 16 --min-percent 90 +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. -Detailed descriptions of the above parameters, as well as their default values, can be obtained by using the `--help` flag. +## Reference -Below is a visual example of the parameters used in threshold mode (click for full-view): + - [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/latest/cli/config_file.html) -[parameters in threshold mode](https://github.com/Breakthrough/PySceneDetect/raw/resources/images/threshold-param-example.png) +## Help & Contributing -You can download the file `testvideo.mp4`, as well as the expected output `testvideo-results.txt`, [from the resources branch](https://github.com/Breakthrough/PySceneDetect/tree/resources/tests), for testing the operation of the program. Data for the above graph was obtained by running PySceneDetect on `testvideo.mp4` in statistics mode (by specifying the `-s` argument). +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](https://bcastell.com/about/). -Current Features & Roadmap ----------------------------------------------------------- +## Code Signing -You can [view the latest features and version roadmap on Readthedocs](http://pyscenedetect.readthedocs.org/en/latest/features/). -See [`docs/changelog.md`](https://github.com/Breakthrough/PySceneDetect/blob/master/docs/changelog.md) for a list of changes in each version, or visit [the Releases page](https://github.com/Breakthrough/PySceneDetect/releases) to download a specific version. Feel free to submit any bugs/issues or feature requests to [the Issue Tracker](https://github.com/Breakthrough/PySceneDetect/issues). +This program uses free code signing provided by [SignPath.io](https://signpath.io?utm_source=foundation&utm_medium=github&utm_campaign=PySceneDetect), and a free code signing certificate by the [SignPath Foundation](https://signpath.org?utm_source=foundation&utm_medium=github&utm_campaign=PySceneDetect) -Additional features being planned or in development can be found [here (tagged as `feature`) in the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=is%3Aissue+is%3Aopen+label%3Afeature). You can also find additional information about PySceneDetect at [http://www.bcastell.com/projects/pyscenedetect/](http://www.bcastell.com/projects/pyscenedetect/). +## License +BSD-3-Clause; see [`LICENSE`](LICENSE) and [`THIRD-PARTY.md`](THIRD-PARTY.md) for details. ---------------------------------------------------------- -Licensed under BSD 3-Clause (see the `LICENSE` file for details). - -Copyright (C) 2012-2018 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/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 00000000..959c2e4f --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,75 @@ +# Ancillary Software Licenses + +This file includes license information for various open-source projects +that are imported, derived into, or distributed with PySceneDetect. +See [LICENSE](LICENSE) for the main PySceneDetect license. + +Depending on the features being used, PySceneDetect uses the following +third-party software which are released under the terms detailed below. +By downloading, copying, installing or using this software, you agree +to these terms. + +In no particular order: + +----------------------------------------------------------------------- + +> click [Copyright (C) 2017, Armin Ronacher]: + This software uses OpenCV; see thirdparty/LICENSE-CLICK or visit: + [ http://click.pocoo.org/license/ ] + +> NumPy [Copyright (C) 2005-2016, Numpy Developers]: + This software uses Numpy; see thirdparty/LICENSE-NUMPY or visit: + [ http://www.numpy.org/license.html ] + +> OpenCV [Copyright (C) 2017, Itseez]: + This software uses OpenCV; see thirdparty/LICENSE-OPENCV or visit: + [ http://opencv.org/license.html ] + +> PyAV [Copyright (C) 2017, Mike Boers and others]: + This software uses PyAV; see thirdparty/LICENSE-PYAV or visit: + [ https://github.com/PyAV-Org/PyAV/blob/main/LICENSE.txt ] + +> pytest [Copyright (C) 2004-2017, Holger Krekel and others]: + This software uses pytest; see thirdparty/LICENSE-PYTEST or visit: + [ https://docs.pytest.org/en/latest/license.html ] + +> simpletable [Copyright (C) 2014-2019, Matheus Vieira Portela and others]: + This software uses simpletable; see thirdparty/LICENSE-SIMPLETABLE or visit: + [ https://github.com/matheusportela/simpletable/blob/master/LICENSE ] + +> tqdm [Copyright (C) 2013-2018, Casper da Costa-Luis, + Google Inc., and Noam Yorav-Raphael]: + This software uses tqdm; see thirdparty/LICENSE-TQDM or visit: + [ https://github.com/tqdm/tqdm/blob/master/LICENCE ] + +> MoviePy [ Copyright (C) 2015 Zulko ] + This software uses tqdm; see thirdparty/LICENSE-TQDM or visit: + [ https://github.com/Zulko/moviepy/blob/master/LICENCE.txt ] + +----------------------------------------------------------------------- + +This software may also invoke FFmpeg or mkvmerge, if available. If required, +these programs can be obtained from following URLs: + + FFmpeg: [ https://ffmpeg.org/download.html ] + mkvmerge: [ https://mkvtoolnix.download/downloads.html ] + +Once installed, ensure the program is in your PATH variable (i.e. you can +run the `ffmpeg` or `mkvmerge` command from any location). + +Certain distributions of PySceneDetect may include ffmpeg. See +thirdparty/LICENSE-FFMPEG file or visit [ https://ffmpeg.org ] + +FFmpeg is a trademark of Fabrice Bellard +mkvmerge is Copyright (C) 2005-2016, Matroska + +Windows distributions may include a compiled Python distribution. For license +information regarding the distributed version of Python, see the +thirdparty/LICENSE-PYTHON file, or visit [ https://docs.python.org/3/license.html ] + +----------------------------------------------------------------------- + +If any information above is incorrect, please let us know. +Visit [ https://www.scenedetect.com ] for contact information. + + diff --git a/USAGE.md b/USAGE.md deleted file mode 100644 index f4662b74..00000000 --- a/USAGE.md +++ /dev/null @@ -1,148 +0,0 @@ - -PySceneDetect -========================================================== - -For extended usage information, see [the PySceneDetect manual (manual.scenedetect.com)](http://manual.scenedetect.com). - - -Usage (Command Line) ----------------------------------------------------------- - -In order to effectively use PySceneDetect, you should become familiar with the basic command line options (especially the detection method commands `detect-content` and `detect-threshold`, both of which have an adjustable threshold value option `-t` / `--threshold`). Descriptions for all command-line arguments/options can be obtained by running PySceneDetect with the `help` command, `help all`, or `help [command]` (e.g. `help detect-content`). - -There are two main detection methods PySceneDetect uses: threshold (`detect-threshold`, comparing each frame to a set black level, useful for detecting cuts and fades to/from black), and content (`detect-content`, compares each frame sequentially looking for changes in content, useful for detecting fast cuts between video scenes, although slower to process). Each mode has slightly different parameters, and is described in detail below. - -If the video uses a lot of fast cuts between content, and has no well-defined scene boundaries, you should use the `detect-content` mode. Use `detect-threshold` mode if you want to detect scene boundaries using fades/cuts in/out to black. - -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 paramters - specifically, the proper threshold value. It is always recommended to generate a stats file to speed up subsequent calls to PySceneDetect for the same input video(s). - - -### Content-Aware Detection Mode - -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. A good threshold value to try when using content-aware mode (`detect-content`) is `30` (`-t 30`), which is the default, for example the following two commands are equivalent: - -```rst -scenedetect -i my_video.mp4 -s my_video.stats.csv detect-content - -scenedetect -i my_video.mp4 -s my_video.stats.csv detect-content -t 30 -``` - -The optimal threshold can be determined by generating a statsfile (`-s`) as shown above, opening it with a spreadsheet editor (e.g. Excel), and examining the `content_val` column. 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). - -To automatically split the video based on the detected scenes (will save starting from `my_video-Scene-001.mp4`, call `help split-video` for details on changing the output filename format), we add the `split-video` command at the end: - -```rst -scenedetect -i my_video.mp4 -s my_video.stats.csv detect-content -t 30 split-video -``` - - -### Threshold-Based Detection Mode - -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). A good threshold value to try when using threshold mode (`detect-threshold`) is `12` (`-t 12`), with a minimum percentage of at least 90% (`-m 0.9`). Using values less than `8` may cause problems with some videos (especially those encoded at lower quality bitrates). - -The optimal threshold can be determined by generating a statsfile (`-s`), opening it with a spreadsheet editor (e.g. Excel), and examining the `delta_rgb` column. These values represent the average intensity of the pixels for that particular frame (taken by averaging the R, G, and B values over the whole frame). The threshold value should be set so that the average intensity of most frames in content scenes lie above the threshold value, and scenes where scene changes/breaks occur should fall *under* the threshold value (thus triggering a scene change). - - -Usage (Python) ----------------------------------------------------------- - -PySceneDetect can also be used from within other Python programs. This allows you to perform scene detection directly in Python code using a `SceneManager`, which allows adding specific `SceneDetector` objects. You can then perform scene detection on frames obtained from a `VideoManager` object (similar to an OpenCV `VideoCapture` object but with additional features to facilitate scene detection, like frame-accurate seeking support). - -The complete PySceneDetect Python API Reference can be found at the following URL: - -[http://pyscenedetect-api.readthedocs.io/](http://pyscenedetect-api.readthedocs.io/) - -Performing scene detection/segmenting live video streams is only supported by the API currently, not the CLI. See the API documentation on the parameters a `VideoManager` object constructor takes for details (pass a list containing the device ID instead of a filename, e.g. `[1]` for device 1). - -The general usage workflow is to determine which detection method and threshold to use (this can even be done iteratively), using these values to create a `SceneDetector` object, the type of which depends on the detection method you want to use (e.g. `ThresholdDetector`, `ContentDetector`). These detectors are then added to a `SceneManager` class, with optionally a `StatsManager` to cache frame metrics so subsequent scene detection runs are much faster (and can be saved/loaded to/from disk). Finally, an open `VideoManager` object can be passed to the `SceneManager.detect_scenes()` method, which returns the number of frames processed. - -The following shows the contents of [the `api_test.py` file](https://github.com/Breakthrough/PySceneDetect/blob/master/tests/api_test.py) included with the PySceneDetect source code, which provides an example as to the general usage of the PySceneDetect Python API: - - -```python -from __future__ import print_function -import os - -import scenedetect -from scenedetect.video_manager import VideoManager -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.stats_manager import StatsManager -from scenedetect.detectors import ContentDetector - -STATS_FILE_PATH = 'api_test_statsfile.csv' - -def main(): - - print("Running PySceneDetect API test...") - - print("PySceneDetect version being used: %s" % str(scenedetect.__version__)) - - # Create a video_manager point to video file testvideo.mp4. Note that multiple - # videos can be appended by simply specifying more file paths in the list - # passed to the VideoManager constructor. Note that appending multiple videos - # requires that they all have the same frame size, and optionally, framerate. - video_manager = VideoManager(['testvideo.mp4']) - stats_manager = StatsManager() - scene_manager = SceneManager(stats_manager) - # Add ContentDetector algorithm (constructor takes detector options like threshold). - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - - try: - # If stats file exists, load it. - if os.path.exists(STATS_FILE_PATH): - # Read stats from CSV file opened in read mode: - with open(STATS_FILE_PATH, 'r') as stats_file: - stats_manager.load_from_csv(stats_file, base_timecode) - - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 20.0 # 00:00:20.000 - # Set video_manager duration to read frames from 00:00:00 to 00:00:20. - video_manager.set_duration(start_time=start_time, end_time=end_time) - - # Set downscale factor to improve processing speed. - video_manager.set_downscale_factor() - - # Start video_manager. - video_manager.start() - - # Perform scene detection on video_manager. - scene_manager.detect_scenes(frame_source=video_manager, - start_time=start_time) - - # Obtain list of detected scenes. - scene_list = scene_manager.get_scene_list(base_timecode) - # Like FrameTimecodes, each scene in the scene_list can be sorted if the - # list of scenes becomes unsorted. - - print('List of scenes obtained:') - 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(),)) - - # We only write to the stats file if a save is required: - if stats_manager.is_save_required(): - with open(STATS_FILE_PATH, 'w') as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode) - - finally: - video_manager.release() - -if __name__ == "__main__": - main() -``` - - -The scene list returned by the `SceneManager.get_scene_list(...)` method consists of the start and (one past) the end frame of each scene, in the form of a `FrameTimecode` object. Each `FrameTimecode` can be converted to the appropriate working/output format via the `get_timecode()`, `get_frames()`, or `get_sceonds()` methods as shown above; see the API documentation for `FrameTimecode` objects for details. - - ----------------------------------------------------------- - - -Licensed under BSD 3-Clause (see the `LICENSE` file for details). - -Copyright (C) 2012-2018 Brandon Castellano. -All rights reserved. diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..0ffafc7c --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,156 @@ +# Build signed releases for PySceneDetect Windows x64 + +build: false + +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.+/ + +skip_tags: false +skip_non_tags: true + +environment: + matrix: + - PYTHON: "C:\\Python313-x64" + # Encrypted AdvancedInstaller License + ai_license_secret: + secure: QRCPoNYF1nqgXDn7pHgBzg== + ai_license_salt: + secure: +Gy+SRk8JUsaM+5pMEKITiJxdLilrxHpkKlrZzR3C9DPwdgYLGxt5sJn6uXuAJg7e6JsKHcT7tRks/HcSKkHPw== + ffmpeg_version: "8.1.2" + +# SignPath Config for Code Signing +deploy: +- provider: Webhook + 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 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + - echo * * SETTING UP PYTHON ENVIRONMENT * * + - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' + - python --version + - 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. 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 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% + - if not exist advinst.msi appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi + - msiexec /i advinst.msi /qn + # Resolve the installed Advanced Installer bin path dynamically - the upstream + # MSI is unversioned so the directory name (Advanced Installer X.Y.Z) drifts. + - 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 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 + - cp scenedetect\scenedetect.exe . + - 7z a scenedetect-signed.zip scenedetect.exe PySceneDetect-*.msi + - cd .. + +test_script: + - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + - echo * * TESTING BUILD * * + - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + # Checkout required test resources + - 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/ + - move dist\scenedetect\ffmpeg.exe ffmpeg.exe + # Run unit tests + # 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 + - scenedetect.exe version + - scenedetect.exe -i ../../tests/resources/testvideo.mp4 -b opencv detect-content time -e 2s + - scenedetect.exe -i ../../tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s + +artifacts: + # 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/docs/other/resources.md b/benchmark/AutoShot/.gitkeep similarity index 100% rename from docs/other/resources.md rename to benchmark/AutoShot/.gitkeep diff --git a/docs/other/thirdparty.md b/benchmark/BBC/.gitkeep similarity index 100% rename from docs/other/thirdparty.md rename to benchmark/BBC/.gitkeep 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/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/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/manual/Makefile b/docs/Makefile similarity index 100% rename from manual/Makefile rename to docs/Makefile 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.css b/docs/_static/pyscenedetect.css new file mode 100644 index 00000000..bafe76a4 --- /dev/null +++ b/docs/_static/pyscenedetect.css @@ -0,0 +1,34 @@ +.sig { + padding-bottom: 8px; +} +.field-list { + padding-bottom: 8px; +} +.class { + padding-bottom: 12px; + padding-top: 4px; +} +.function { + padding-bottom: 12px; + padding-top: 12px; +} +.data { + padding-bottom: 12px; + padding-top: 12px; +} +.method { + padding-bottom: 12px; + padding-top: 12px; +} +.attribute { + padding-bottom: 12px; + padding-top: 12px; +} +.exception { + padding-bottom: 12px; + padding-top: 12px; +} +.property { + padding-bottom: 12px; + padding-top: 12px; +} diff --git a/docs/_static/pyscenedetect_logo.png b/docs/_static/pyscenedetect_logo.png new file mode 100644 index 00000000..26cb1b24 Binary files /dev/null and b/docs/_static/pyscenedetect_logo.png differ diff --git a/docs/_static/pyscenedetect_logo_small.png b/docs/_static/pyscenedetect_logo_small.png new file mode 100644 index 00000000..a0180fb9 Binary files /dev/null and b/docs/_static/pyscenedetect_logo_small.png differ diff --git a/docs/_templates/navigation.html b/docs/_templates/navigation.html new file mode 100644 index 00000000..4493c591 --- /dev/null +++ b/docs/_templates/navigation.html @@ -0,0 +1,10 @@ +

{{ _('Navigation') }}

+{{ toctree(maxdepth=2, includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }} +{% if theme_extra_nav_links %} +
+
    + {% for text, uri in theme_extra_nav_links.items() %} +
  • {{ text }}
  • + {% endfor %} +
+{% endif %} \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..650975b4 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,106 @@ + +*********************************************************************** +``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 is organized into several sub-modules: + + * :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.detectors 🕵️ `: detection algorithms: + + * :class:`AdaptiveDetector `: finds fast cuts using rolling average of HSL changes + + * :class:`ContentDetector `: detects fast cuts using weighted average of HSV changes + + * :class:`ThresholdDetector `: finds fades in/out using average pixel intensity changes in RGB + + * :class:`HistogramDetector `: finds fast cuts using HSV histogram changes + + * :class:`HashDetector `: finds fast cuts using perceptual image hashing + + * :ref:`scenedetect.output ✂️ `: Output formats: + + * :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 ` + + * PyAV: :class:`VideoStreamAv ` + + * MoviePy: :class:`VideoStreamMoviePy ` + + * Multiple videos can be treated as a single continuous stream using :class:`VideoStreamConcat ` (e.g. ``open_video(["part1.mp4", "part2.mp4"])``) + + * :ref:`scenedetect.common ⏱️ `: common functionality such as :class:`FrameTimecode ` for timecode handling + + * :ref:`scenedetect.scene_manager 🎞️ `: the :class:`SceneManager ` coordinates performing scene detection on a video with one or more detectors + + * :ref:`scenedetect.detector 🌐 `: the interface (:class:`SceneDetector `) that detectors must implement to be compatible with PySceneDetect + + * :ref:`scenedetect.video_stream 📹 `: the interface (:class:`VideoStream `) that 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. + +.. warning:: + + The PySceneDetect API is still under development. It is recommended that you pin the `scenedetect` version in your requirements to below the next major release: + + .. code:: python + + scenedetect~=0.7 + + +.. _scenedetect-quickstart: + +======================================================================= +Getting Started +======================================================================= + +PySceneDetect makes it very easy to find scene transitions in a video with the :func:`scenedetect.detect` function: + +.. code:: python + + from scenedetect import detect, ContentDetector + path = "video.mp4" + scenes = detect(path, ContentDetector()) + for (scene_start, scene_end) in scenes: + 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. + +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 + + from scenedetect import detect, ContentDetector, split_video_ffmpeg + scene_list = detect("my_video.mp4", ContentDetector()) + split_video_ffmpeg("my_video.mp4", scenes) + +Recipes for common use cases can be `found on Github `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:`SceneManager usage examples `. + +.. _scenedetect-functions: + +======================================================================= +Functions +======================================================================= + +.. automodule:: scenedetect + :members: + + +======================================================================= +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. diff --git a/docs/api/backends.rst b/docs/api/backends.rst new file mode 100644 index 00000000..8ba7d47c --- /dev/null +++ b/docs/api/backends.rst @@ -0,0 +1,21 @@ + +.. _scenedetect-backends: + +-------------- +Video Backends +-------------- + +.. automodule:: scenedetect.backends + :members: + +.. automodule:: scenedetect.backends.opencv + :members: + +.. automodule:: scenedetect.backends.pyav + :members: + +.. 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 new file mode 100644 index 00000000..5fbe7ff2 --- /dev/null +++ b/docs/api/detectors.rst @@ -0,0 +1,53 @@ + +.. _scenedetect-detectors: + +--------- +Detectors +--------- + +.. automodule:: scenedetect.detectors + +AdaptiveDetector +================ + +.. automodule:: scenedetect.detectors.adaptive_detector + :no-members: + +.. autoclass:: scenedetect.detectors.adaptive_detector.AdaptiveDetector + :members: + +ContentDetector +=============== + +.. automodule:: scenedetect.detectors.content_detector + :no-members: + +.. autoclass:: scenedetect.detectors.content_detector.ContentDetector + :members: + +HashDetector +============ + +.. automodule:: scenedetect.detectors.hash_detector + :no-members: + +.. autoclass:: scenedetect.detectors.hash_detector.HashDetector + :members: + +HistogramDetector +================= + +.. automodule:: scenedetect.detectors.histogram_detector + :no-members: + +.. autoclass:: scenedetect.detectors.histogram_detector.HistogramDetector + :members: + +ThresholdDetector +================= + +.. automodule:: scenedetect.detectors.threshold_detector + :no-members: + +.. autoclass:: scenedetect.detectors.threshold_detector.ThresholdDetector + :members: diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst new file mode 100644 index 00000000..b481ebff --- /dev/null +++ b/docs/api/migration_guide.rst @@ -0,0 +1,290 @@ + +.. _scenedetect-migration-guide: + +*********************************************************************** +Migration Guide (v0.7) +*********************************************************************** + +PySceneDetect v0.7 is a major release that overhauls timestamp handling to support variable framerate (VFR) videos. While the high-level :func:`scenedetect.detect` workflow is largely unchanged, several internal APIs have been restructured. This guide covers the changes needed to update applications from v0.6 to v0.7. + +The minimum supported Python version is now **Python 3.10**. + + +======================================================================= +Quick Check +======================================================================= + +If your code only uses :func:`scenedetect.detect` with a built-in detector, it should work without changes: + +.. code:: python + + # This still works in v0.7 + from scenedetect import detect, ContentDetector + scenes = detect("video.mp4", ContentDetector()) + + +======================================================================= +Import Changes +======================================================================= + +Several submodules have been reorganized. If you import directly from `scenedetect` you do not need to make any changes. Update imports as follows: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - v0.6 + - v0.7 + * - ``from scenedetect.frame_timecode import FrameTimecode`` + - ``from scenedetect.common import FrameTimecode`` + * - ``from scenedetect.scene_detector import SceneDetector`` + - ``from scenedetect.detector import SceneDetector`` + * - ``from scenedetect.video_splitter import split_video_ffmpeg`` + - ``from scenedetect.output import split_video_ffmpeg`` + * - ``from scenedetect.video_splitter import split_video_mkvmerge`` + - ``from scenedetect.output import split_video_mkvmerge`` + * - ``from scenedetect.scene_manager import save_images`` + - ``from scenedetect.output import save_images`` + * - ``from scenedetect.scene_manager import write_scene_list`` + - ``from scenedetect.output import write_scene_list`` + * - ``from scenedetect.scene_manager import write_scene_list_html`` + - ``from scenedetect.output import write_scene_list_html`` + * - ``from scenedetect.video_manager import VideoManager`` + - Removed. Use :func:`scenedetect.open_video` instead. + +.. note:: + + Most commonly used types and functions are also available directly from the top-level ``scenedetect`` package (e.g. ``from scenedetect import FrameTimecode``), which has not changed. + +.. 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()``. + +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 + + 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. + +``Fraction`` participates in numeric arithmetic and comparisons just like ``float``, and converts implicitly when mixed with floats (the result is a ``float``). When a ``float`` is explicitly required (e.g. format specifiers, ``json.dumps``, ``isinstance(x, float)`` checks) wrap the value with ``float(...)``: + +.. code:: python + + rate = tc.frame_rate # Fraction(30000, 1001) + rate * 2 # Fraction(60000, 1001) + rate > 24 # True + rate * 0.5 # 14.985... (float, mixed arithmetic) + f"{float(rate):.3f}" # '29.970' (explicit cast for format spec) + +The legacy ``framerate`` property (one word, returns ``float``) is retained as a deprecated alias and will emit a ``DeprecationWarning`` in a future release. Migrate to ``frame_rate``; cast with ``float(...)`` at the call site if you specifically need a ``float``. + +Renamed Method: ``equal_framerate()`` +----------------------------------------------------------------------- + +:meth:`~scenedetect.common.FrameTimecode.equal_framerate` has been renamed to :meth:`~scenedetect.common.FrameTimecode.equal_frame_rate` for consistency with the :attr:`~scenedetect.common.FrameTimecode.frame_rate` property. The legacy ``equal_framerate()`` method is retained as a deprecated alias and will emit a ``DeprecationWarning`` in a future release. The new form additionally accepts a ``Fraction`` or another ``FrameTimecode`` (in addition to ``float``). + +Removed Methods +----------------------------------------------------------------------- + +- ``previous_frame()`` - removed, use ``FrameTimecode(tc.frame_num - 1, tc)`` 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 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. + +.. code:: python + + # v0.6 - will still work but will be removed in a future version + video = open_video("video.mp4", framerate=30.0) + + # v0.7 + video = open_video("video.mp4", frame_rate=30.0) + +PTS-Backed Timestamps +----------------------------------------------------------------------- + +All backends now return presentation timestamp (PTS) backed values from :attr:`~scenedetect.video_stream.VideoStream.position`. This enables correct handling of VFR videos. + +``FrameTimecode`` has new :attr:`~scenedetect.common.FrameTimecode.time_base` and :attr:`~scenedetect.common.FrameTimecode.pts` properties for accessing the underlying timing information. For VFR videos, :attr:`~scenedetect.common.FrameTimecode.frame_num` is now an approximation based on PTS-derived time rather than a sequential count. + + +======================================================================= +``StatsManager`` Changes +======================================================================= + +The ``StatsManager`` methods :meth:`~scenedetect.stats_manager.StatsManager.get_metrics`, :meth:`~scenedetect.stats_manager.StatsManager.set_metrics`, and :meth:`~scenedetect.stats_manager.StatsManager.metrics_exist` now formally accept either a ``FrameTimecode`` or a plain ``int`` frame number for the timecode argument. Passing a ``FrameTimecode`` is preferred and matches the detector interface; the ``int`` form is retained for compatibility with the deprecated ``load_from_csv()`` path, which keys metrics by integer frame number. + +``StatsManager.load_from_csv()`` also accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str`` / ``bytes`` / file handles. + + +======================================================================= +``SceneDetector`` Annotation Fixes +======================================================================= + +:meth:`~scenedetect.detector.SceneDetector.post_process` now declares its parameter as ``timecode: FrameTimecode`` (previously typed as ``int``). The method already received a ``FrameTimecode`` at runtime and concrete detectors (e.g. ``ThresholdDetector``, ``ContentDetector``) already used the ``FrameTimecode`` type - only the abstract-base-class annotation was inconsistent. No call-site changes are needed; this just brings the signature into agreement with the documented and actual behavior. + + +======================================================================= +``SceneManager.detect_scenes()`` Time Arguments +======================================================================= + +The ``duration`` and ``end_time`` arguments of :meth:`~scenedetect.scene_manager.SceneManager.detect_scenes` now formally accept ``int`` (frames), ``float`` (seconds), ``str`` (timecode string, e.g. ``"00:00:05.000"``), or ``FrameTimecode``. The internal code already validated these forms; the annotation was previously narrower than the documented behavior. + +.. code:: python + + # All of these were always supported at runtime; now they type-check too: + scene_manager.detect_scenes(video, end_time=15.0) # seconds + scene_manager.detect_scenes(video, end_time=1500) # frames + scene_manager.detect_scenes(video, end_time="00:01:00") # timecode + + +======================================================================= +``save_images()`` Path Handling +======================================================================= + +The ``output_dir`` argument of :func:`scenedetect.output.save_images` now accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str``. No changes are required for existing string-based callers. + + +======================================================================= +Removed APIs +======================================================================= + +The following deprecated APIs have been fully removed in v0.7: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Removed + - Replacement + * - ``scenedetect.video_manager`` module + - :func:`scenedetect.open_video` + * - ``base_timecode`` parameter (various functions) + - No longer needed, remove the argument + * - ``video_manager`` parameter (various functions) + - Use ``video`` parameter instead + * - ``SceneManager.get_event_list()`` + - Use :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`` + +.. note:: + + Deprecated v0.6 compatibility shims that still exist now emit warnings using the ``warnings`` module. Address any ``DeprecationWarning`` messages to prepare for future releases. + + +======================================================================= +CLI Changes +======================================================================= + +Removed / Renamed +----------------------------------------------------------------------- + +- The ``-d``/``--min-delta-hsv`` option on ``detect-adaptive`` has been removed. Use ``-c``/``--min-content-val`` instead. +- The global ``--framerate`` flag has been renamed to ``-f``/``--frame-rate`` for consistency with the API. The legacy ``--framerate`` form is retained as a hidden alias and will be removed in v0.8; if both are supplied, ``--frame-rate`` takes precedence and a warning is logged. +- The ``export-html`` command has been renamed to :ref:`save-html `. The legacy ``export-html`` command is retained as a deprecated alias and emits a deprecation warning when used. + +New Commands and Options +----------------------------------------------------------------------- + +- New :ref:`save-fcp ` command exports scenes in Final Cut Pro XML format (FCP7/FCPX). +- New :ref:`save-qp ` command writes a QP file with scene boundary frame numbers, suitable for forcing keyframes at scene cuts in x264/x265. +- New :ref:`save-html ` command (replaces ``export-html``). +- New ``-s``/``--start-timecode`` option on :ref:`save-edl ` provides a custom start timecode for generated EDLs (SMPTE ``HH:MM:SS:FF`` or 8-digit ``HHMMSSFF``). + +Other Changes +----------------------------------------------------------------------- + +- VFR videos now work correctly with both the OpenCV and PyAV backends. +- 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 new file mode 100644 index 00000000..134fa2d1 --- /dev/null +++ b/docs/api/platform.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-platform: + +------------------ +Platform & Logging +------------------ + +.. automodule:: scenedetect.platform + :members: diff --git a/docs/api/scene_manager.rst b/docs/api/scene_manager.rst new file mode 100644 index 00000000..2a0ee6a3 --- /dev/null +++ b/docs/api/scene_manager.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-scene_manager: + +------------- +Scene Manager +------------- + +.. automodule:: scenedetect.scene_manager + :members: diff --git a/docs/api/stats_manager.rst b/docs/api/stats_manager.rst new file mode 100644 index 00000000..5f96dec0 --- /dev/null +++ b/docs/api/stats_manager.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-stats_manager: + +------------- +Stats Manager +------------- + +.. automodule:: scenedetect.stats_manager + :members: diff --git a/docs/api/video_stream.rst b/docs/api/video_stream.rst new file mode 100644 index 00000000..a090d6d9 --- /dev/null +++ b/docs/api/video_stream.rst @@ -0,0 +1,9 @@ + +.. _scenedetect-video_stream: + +---------------- +Stream Interface +---------------- + +.. automodule:: scenedetect.video_stream + :members: diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 3cf724d7..00000000 --- a/docs/changelog.md +++ /dev/null @@ -1,123 +0,0 @@ - -PySceneDetect Changelog -========================================================== - - -## 0.5 (August 31, 2017)   - - * **major** release, includes stable Python API with examples and updated documentation - * numerous changes to command-line interface with addition of sub-commands (see [the new manual](http://manual.scenedetect.com) for updated usage information) - * [feature] videos are now split using `ffmpeg` by default, resulting in frame-perfect cuts (can still use `mkvmerge` by specifying the `-c`/`--copy` argument to the `split-video` command) - * [enhance] image filename numbers are now consistent with those of split video scenes (PR #39, thanks [@e271828-](https://github.com/Breakthrough/PySceneDetect/pull/39)) - * [enhance] 5-10% improvement in processing performance due to reduced memory copy operations (PR #40, thanks [@elcombato] - (https://github.com/Breakthrough/PySceneDetect/pull/40)) - * [enhance] updated exception handling to raise proper standard exceptions (PR #37, thanks [@talkain](https://github.com/Breakthrough/PySceneDetect/pull/37)) - * several fixes to the documentation, including improper dates and outdated CLI arguments (PR #26 and #, thanks [@elcombato] - (https://github.com/Breakthrough/PySceneDetect/pull/26), and [@colelawrence](https://github.com/Breakthrough/PySceneDetect/pull/33)) - * *numerous* other PRs and issues/bug reports that have been fixed - there are too many to list individually here, so I want to extend a big thank you to **everyone** who contributed to making this release better - * [enhance] add Sphinx-generated API documentation (available at: http://manual.scenedetect.com) - * [project] move from BSD 2-clause to 3-clause license - - -## 0.4 (January 14, 2017) - - * major release, includes integrated scene splitting via mkvmerge, changes meaning of `-o` / `--output` option - * [feature] specifying `-o OUTPUT_FILE.mkv` will now automatically split the input video, generating a new video clip for each detected scene in sequence, starting with `OUTPUT_FILE-001.mkv` - * [enhance] CSV file output is now specified with the `-co` / `--csv-output` option (*note, used to be `-o` in versions of PySceneDetect < 0.4*) - - -### 0.3.6 (January 12, 2017) - - * [enhance] performance improvement when using `--frameskip` option (thanks [@marcelluzs](https://github.com/marcelluzs)) - * [internal] moved application state and shared objects to a consistent interface (the `SceneManager` object) to greatly reduce the number of required arguments for certain API functions - * [enhance] added installer for Windows builds (64-bit only currently) - - -### 0.3.5 (August 2, 2016) - - * [enhance] initial release of portable build for Windows (64-bit only), including all dependencies - * [bugfix] fix unrelated exception thrown when video could not be loaded (thanks [@marcelluzs](https://github.com/marcelluzs)) - * [internal] fix variable name typo in API documentation - - -### 0.3.4 (February 8, 2016) - - * [enhance] add scene length, in seconds, to output file (`-o`) for easier integration with `ffmpeg`/`libav` - * [enhance] improved performance of content detection mode by caching intermediate HSV frames in memory (approx. 2x faster) - * [enhance] show timecode values in terminal when using extended output (`-l`) - * [feature] add fade bias option (`-fb` / `--fade-bias`) to command line (threshold mode only) - - -### 0.3.3 (January 27, 2016) - - * [bugfix] output scenes are now correctly written to specified output file when using -o flag (fixes #11) - * [bugfix] fix indexing exception when using multiple scene detectors and outputting statistics - * [internal] distribute package on PyPI, version move from beta to stable - * [internal] add function to convert frame number to formatted timecode - * [internal] move file and statistic output to Python `csv` module - - -### 0.3.2-beta (January 26, 2016) - - * [feature] added `-si` / `--save-images` flag to enable saving the first and last frames of each detected scene as an image, saved in the current working directory with the original video filename as the output prefix - * [feature] added command line options for setting start and end times for processing (`-st` and `-et`) - * [feature] added command line option to specify maximum duration to process (`-dt`, overrides `-et`) - - -### 0.3.1-beta (January 23, 2016) - - * [feature] added downscaling/subsampling option (`-df` / `--downscale-factor`) to improve performance on higher resolution videos - * [feature] added frameskip option (`-fs` / `--frame-skip`) to improve performance on high framerate videos, at expense of frame accuracy and possible inaccurate scene cut prediction - * [enhance] added setup.py to allow for one-line installation (just run `python setup.py install` after downloading and extracting PySceneDetect) - * [internal] additional API functions to remove requirement on passing OpenCV video objects, and allow just a file path instead - - -## 0.3-beta (January 8, 2016) - - * major release, includes improved detection algorithms and complete internal code refactor - * [feature] content-aware scene detection using HSV-colourspace based algorithm (use `-d content`) - * [enhance] added CLI flags to allow user changes to more algorithm properties - * [internal] re-implemented threshold-based scene detection algorithm under new interface - * [internal] major code refactor including standard detection algorithm interface and API - * [internal] remove statistics mode until update to new detection mode interface - - ----------------------------------------------------------------- - - -### 0.2.4-alpha (December 22, 2015) - * [bugfix] updated OpenCV compatibility with self-reported version on some Linux distributions - - -### 0.2.3-alpha (August 7, 2015) - * [bugfix] updated PySceneDetect to work with latest OpenCV module (ver > 3.0) - * [bugfix] added compatibility/legacy code for older versions of OpenCV - * [feature] statsfile generation includes expanded frame metrics - - -### 0.2.2-alpha (November 25, 2014) - - * [feature] added statistics mode for generating frame-by-frame analysis (-s / --statsfile flag) - * [bugfix] fixed improper timecode conversion - - -### 0.2.1-alpha (November 16, 2014) - - * [enhance] proper timecode format (HH:MM:SS.nnnnn) - * [enhance] one-line of CSV timecodes added for easy splitting with external tool - - -## 0.2-alpha (June 9, 2014) - - * [enhance] now provides discrete scene list (in addition to fades) - * [feature] ability to output to file (-o / --output flag) - - ----------------------------------------------------------------- - - -## 0.1-alpha (June 8, 2014) - - * first public release - * [feature] threshold-based fade in/out detection - diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 index 00000000..de49ad40 --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,909 @@ +.. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified. + +************************************************************************ +``scenedetect`` 🎬 Command +************************************************************************ + + +.. _command-scenedetect: + +.. program:: scenedetect + +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 -i video.mp4 [detector] [commands]`` + +For [detector] use :ref:`detect-adaptive ` or :ref:`detect-content ` to find fast cuts, and :ref:`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 -i video.mp4 split-video`` + +Save scene list in CSV format with images at the start, middle, and end of each scene: + + ``scenedetect -i video.mp4 list-scenes save-images`` + +Skip the first 10 seconds of the input video: + + ``scenedetect -i video.mp4 time --start 10s detect-content`` + +Show summary of all options and commands: + + ``scenedetect --help`` + +Global options (e.g. :option:`-i/--input <-i>`, :option:`-c/--config <-c>`) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. + + +************************************************************************ +Options +************************************************************************ + + +.. option:: -i VIDEO, --input VIDEO + + [REQUIRED] Input video file. Image sequences and URLs are supported. + +.. option:: -o DIR, --output DIR + + Output directory for created files. If unset, working directory will be used. May be overridden by command options. + +.. option:: -c FILE, --config FILE + + Path to config file. See :ref:`config file reference ` for details. + +.. option:: -s CSV, --stats CSV + + Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis. + +.. option:: -f FPS, --frame-rate FPS + + 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 (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633). + + Default: ``0.6s`` + +.. option:: --drop-short-scenes + + Drop scenes shorter than :option:`-m/--min-scene-len <-m>`, instead of combining with neighbors. + +.. option:: --merge-last-scene + + Merge last scene with previous if shorter than :option:`-m/--min-scene-len <-m>`. + +.. option:: -b BACKEND, --backend BACKEND + + Backend to use for video input. Backend options can be set using a config file (:option:`-c/--config <-c>`). [available: opencv, pyav, moviepy] + + 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 -d 1 to disable downscaling. + +.. option:: -fs N, --frame-skip N + + 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`` + +.. option:: -v LEVEL, --verbosity LEVEL + + Amount of information to show. LEVEL must be one of: debug, info, warning, error, none. Overrides :option:`-q/--quiet <-q>`. + + Default: ``info`` + +.. option:: -l FILE, --logfile FILE + + Save debug log to FILE. Appends to existing file if present. + +.. option:: -q, --quiet + + Suppress output to terminal/stdout. Equivalent to setting :option:`--verbosity=none <--verbosity>`. + + +.. _command-help: + +``help``, ``version``, and ``about`` +======================================================================= + +.. program:: scenedetect help + +``scenedetect --help`` will print PySceneDetect options, commands, and examples. You can also specify: + + * ``scenedetect [command] --help`` to show options and examples *for* a command or detector + + * ``scenedetect help`` command to print full reference of all options, commands, and examples + +.. program:: scenedetect version + +``scenedetect version`` prints the version of PySceneDetect that is installed, as well as system dependencies. + +.. program:: scenedetect about + +``scenedetect about`` prints PySceneDetect copyright, licensing, and redistribution information. This includes a list of all third-party software components that PySceneDetect uses or interacts with, as well as a reference to the license and copyright information for each component. + +************************************************************************ +Detectors +************************************************************************ + + +.. _command-detect-adaptive: + +.. program:: scenedetect detect-adaptive + + +``detect-adaptive`` +======================================================================== + +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. + + +Examples +------------------------------------------------------------------------ + + + ``scenedetect -i video.mp4 detect-adaptive`` + + ``scenedetect -i video.mp4 detect-adaptive --threshold 3.2`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -t VAL, --threshold VAL + + Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file. + + Default: ``3.0`` + +.. option:: -c VAL, --min-content-val VAL + + Minimum threshold (float) that "content_val" must exceed to trigger a cut. + + 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. + + Default: ``2`` + +.. option:: -w, --weights + + Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val". + + Default: ``1.000, 1.000, 1.000, 0.000`` + +.. option:: -l, --luma-only + + Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0". + +.. option:: -k N, --kernel-size N + + Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution. + + Default: ``auto`` + +.. 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 (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). + + +.. _command-detect-content: + +.. program:: scenedetect detect-content + + +``detect-content`` +======================================================================== + +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_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 :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. + + +Examples +------------------------------------------------------------------------ + + + ``scenedetect -i video.mp4 detect-content`` + + ``scenedetect -i video.mp4 detect-content --threshold 27.5`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -t VAL, --threshold VAL + + 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`` + +.. option:: -w HUE SAT LUM EDGE, --weights HUE SAT LUM EDGE + + Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges). + + Default: ``1.000, 1.000, 1.000, 0.000`` + +.. option:: -l, --luma-only + + Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0. + +.. option:: -k N, --kernel-size N + + 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. + + Default: ``auto`` + +.. option:: -m TIMECODE, --min-scene-len TIMECODE + + 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: + +.. program:: scenedetect detect-hash + + +``detect-hash`` +======================================================================== + +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 -i video.mp4 detect-hash`` + + ``scenedetect -i video.mp4 detect-hash --size 32 --lowpass 3`` + + +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.35`` + +.. option:: -s SIZE, --size SIZE + + Size of square of low frequency data to include from the discrete cosine transform. + + Default: ``8`` + +.. option:: -l FRAC, --lowpass FRAC + + 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... + + Default: ``2`` + +.. option:: -m TIMECODE, --min-scene-len TIMECODE + + 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: + +.. program:: scenedetect detect-hist + + +``detect-hist`` +======================================================================== + +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 -i video.mp4 detect-hist`` + + ``scenedetect -i video.mp4 detect-hist --threshold 0.1 --bins 240`` + + +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.2`` + +.. option:: -b NUM, --bins NUM + + The number of bins to use for the histogram calculation. + + Default: ``128`` + +.. option:: -m TIMECODE, --min-scene-len TIMECODE + + 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: + +.. program:: scenedetect detect-threshold + + +``detect-threshold`` +======================================================================== + +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 -i video.mp4 detect-threshold`` + + ``scenedetect -i video.mp4 detect-threshold --threshold 15`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -t VAL, --threshold VAL + + Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file. + + Default: ``12.0`` + +.. option:: -f PERCENT, --fade-bias PERCENT + + 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. + + Default: ``0`` + +.. option:: -l, --add-last-scene + + If set and video ends after a fade-out event, generate a final cut at the last fade-out position. + + Default: ``True`` + +.. 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 (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). + + +************************************************************************ +Commands +************************************************************************ + + +.. _command-list-scenes: + +.. program:: scenedetect list-scenes + + +``list-scenes`` +======================================================================== + +Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). + + +Examples +------------------------------------------------------------------------ + + +Default: + + ``scenedetect -i video.mp4 list-scenes`` + +Without cut list (RFC 4180 compliant CSV): + + ``scenedetect -i video.mp4 list-scenes --skip-cuts`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -o DIR, --output DIR + + 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. -f \$VIDEO_NAME-Scenes.csv). + + Default: ``$VIDEO_NAME-Scenes.csv`` + +.. option:: -n, --no-output-file + + Only print scene list. + +.. option:: -q, --quiet + + Suppress printing scene list. + +.. option:: -s, --skip-cuts + + Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output. + + +.. _command-load-scenes: + +.. program:: scenedetect load-scenes + + +``load-scenes`` +======================================================================== + +Load scenes from CSV instead of detecting. Can be used with CSV generated by :ref:`list-scenes `. Scenes are loaded using the specified column as cut locations (frame number or timecode). + + +Examples +------------------------------------------------------------------------ + + + ``scenedetect -i video.mp4 load-scenes -i scenes.csv`` + + ``scenedetect -i video.mp4 load-scenes -i scenes.csv --start-col-name "Start Timecode"`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -i FILE, --input FILE + + Scene list to read cut information from. + +.. option:: -c STRING, --start-col-name STRING + + Name of column used to mark scene cuts. + + 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 + + +``save-images`` +======================================================================== + +Save images from each detected scene. + + +Examples +------------------------------------------------------------------------ + + + ``scenedetect -i video.mp4 save-images --num-images 5`` + + ``scenedetect -i video.mp4 save-images --width 1024`` + + ``scenedetect -i video.mp4 save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -o DIR, --output DIR + + 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. -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 -n 1, in which case the image will be the frame at the mid-point of the scene. + + Default: ``3`` + +.. option:: -j, --jpeg + + Set output format to JPEG (default). + +.. option:: -w, --webp + + Set output format to WebP + +.. option:: -q Q, --quality Q + + JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. + + Default: ``JPEG: 95, WebP: 100`` + +.. option:: -p, --png + + Set output format to PNG. + +.. option:: -c C, --compression C + + 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. + + Default: ``3`` + +.. option:: -m DURATION, --frame-margin DURATION + + 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: ``1`` + +.. option:: -s S, --scale S + + Factor to scale images by. Ignored if :option:`-W/--width <-W>` or :option:`-H/--height <-H>` is set. + +.. option:: -H H, --height H + + Height (pixels) of images. + +.. option:: -W W, --width W + + 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 + + +``split-video`` +======================================================================== + +Split input video using ffmpeg or mkvmerge. + + +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`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -o DIR, --output DIR + + 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. -f \$VIDEO_NAME-Scene-\$SCENE_NUMBER). + + Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER`` + +.. option:: -q, --quiet + + Hide output from external video splitting tool. + +.. option:: -c, --copy + + Copy instead of re-encode. Faster but less precise. + +.. option:: -hq, --high-quality + + Encode video with higher quality, overrides -f option if present. Equivalent to: :option:`--rate-factor=17 <--rate-factor>` :option:`--preset=slow <--preset>` + +.. option:: -crf RATE, --rate-factor RATE + + Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless. + + Default: ``22`` + +.. option:: -p LEVEL, --preset LEVEL + + Video compression quality (x264 preset). Can be one of: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow. Faster modes take less time but output may be larger. + + Default: ``veryfast`` + +.. option:: -a ARGS, --args ARGS + + Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec. + + Default: ``"-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac"`` + +.. option:: -m, --mkvmerge + + 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: + +.. program:: scenedetect time + + +``time`` +======================================================================== + +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 -i video.mp4 time --end 00:01:00`` + + ``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: + + ``scenedetect -i video.mp4 time --start 0 --end 1000`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -s TIMECODE, --start TIMECODE + + 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 + + Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with :option:`-e/--end <-e>`. + +.. option:: -e TIMECODE, --end TIMECODE + + Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with :option:`-d/--duration <-d>` + diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst new file mode 100644 index 00000000..2e28102b --- /dev/null +++ b/docs/cli/backends.rst @@ -0,0 +1,50 @@ + +.. _cli-backends: + +*********************************************************************** +Backends +*********************************************************************** + +PySceneDetect supports multiple backends for video input. Some can be configured by using :ref:`a config file `. Installed backends can be verified by running ``scenedetect version --all``. + +Note that the `scenedetect` command output is generated as a post-processing step, after scene detection completes. Most commands require the ability for the input to be replayed, and preferably it should also support seeking. Network streams and other input types are supported with certain backends, however integration with live streams requires use of the Python API. + + +======================================================================= +OpenCV +======================================================================= + +*[Default]* +The `OpenCV `_ backend (usually `opencv-python `_) uses OpenCV's ``VideoCapture`` for video input. Can be used by specifying ``-b opencv`` via command line, or setting ``backend = opencv`` under the ``[global]`` section of your :ref:`config file `. + +It is mostly reliable and fast, although can occasionally run into issues processing videos with multiple audio tracks or small amounts of frame corruption. You can use a custom version of the ``cv2`` package, or install either the `opencv-python` or `opencv-python-headless` packages from `pip`. + +The OpenCV backend also supports image sequences as inputs (e.g. ``frame%02d.jpg`` if you want to load frame001.jpg, frame002.jpg, frame003.jpg...). Make sure to specify the framerate manually (``-f``/``--framerate``) to ensure accurate timing calculations. + +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 +======================================================================= + +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 `. + + +======================================================================= +MoviePy +======================================================================= + +MoviePy launches ffmpeg as a subprocess, and can be used with various types of inputs. If the input supports seeking it should work fine with most operations, for example, image sequences or AviSynth scripts. + +.. warning:: + + 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/cli/config_file.rst b/docs/cli/config_file.rst new file mode 100644 index 00000000..95fd9135 --- /dev/null +++ b/docs/cli/config_file.rst @@ -0,0 +1,66 @@ + +.. _scenedetect_cli-config_file: + +*********************************************************************** +Configuration File +*********************************************************************** + +A configuration file path can be specified using the ``-c``/``--config`` argument. PySceneDetect also looks for a config file named `scenedetect.cfg` in one of the following locations: + + * Windows: + * ``C:/Users/%USERNAME%/AppData/Local/PySceneDetect/scenedetect.cfg`` + + * Linux: + * ``~/.config/PySceneDetect/scenedetect.cfg`` + * ``$XDG_CONFIG_HOME/scenedetect.cfg`` + + * Mac: + * ``~/Library/Preferences/PySceneDetect/scenedetect.cfg`` + +Run `scenedetect --help` to see the exact path on your system which will be used. Values set on the command line take precedence over those set in the config file. Most (but not all) command line parameters can be set using a configuration file, and some options can *only* be set using a config file. See the :ref:`Template ` below for a ``scenedetect.cfg`` file that describes each option, which you can use to create a new config file. Note that lines starting with a ``#`` are comments and will be ignored. + +The syntax of a configuration file is: + +.. code:: ini + + [command] + option_a = value + #comment + option_b = 1 + + +======================================================================= +Example +======================================================================= + +.. code:: ini + + [global] + default-detector = detect-content + min-scene-len = 0.8s + + [detect-content] + threshold = 26 + + [split-video] + # Use higher quality encoding + preset = slow + rate-factor = 17 + filename = $VIDEO_NAME-Clip-$SCENE_NUMBER + + [save-images] + format = jpeg + quality = 80 + num-images = 3 + + +.. _config_file Template: + +======================================================================= +Template +======================================================================= + +This template shows every possible configuration option and default values. It can be used as a ``scenedetect.cfg`` file. You can also `download it from Github `_. + +.. literalinclude:: ../../scenedetect.cfg + :language: ini diff --git a/manual/conf.py b/docs/conf.py similarity index 64% rename from manual/conf.py rename to docs/conf.py index b659fe0a..a73c362c 100644 --- a/manual/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # @@ -15,83 +14,75 @@ import os import sys -import alabaster - -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) +from scenedetect import __version__ as scenedetect_version # -- Project information ----------------------------------------------------- -project = 'PySceneDetect' -copyright = '2018, Brandon Castellano' -author = 'Brandon Castellano' +project = "PySceneDetect" +copyright = "2014, Brandon Castellano" +author = "Brandon Castellano" # The short X.Y version -version = '' +version = scenedetect_version # The full version, including alpha/beta/rc tags -release = 'v0.5' - +release = scenedetect_version # -- General configuration --------------------------------------------------- -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.autodoc', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx_copybutton", ] -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +autoclass_content = "both" +autodoc_member_order = "groupwise" +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"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" -# The master toctree document. -master_doc = 'index' +# The root toctree document. +root_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - +pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -#html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +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. @@ -103,12 +94,10 @@ # # html_sidebars = {} - # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'PySceneDetectdoc' - +htmlhelp_basename = "PySceneDetectdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -116,15 +105,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -134,20 +120,14 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'PySceneDetect.tex', 'PySceneDetect Documentation', - 'Brandon Castellano', 'manual'), + (root_doc, "PySceneDetect.tex", "PySceneDetect Documentation", "Brandon Castellano", "manual"), ] - # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'pyscenedetect', 'PySceneDetect Documentation', - [author], 1) -] - +man_pages = [(root_doc, "pyscenedetect", "PySceneDetect Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -155,34 +135,38 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'PySceneDetect', 'PySceneDetect Documentation', - author, 'PySceneDetect', 'One line description of project.', - 'Miscellaneous'), + ( + root_doc, + "PySceneDetect", + "PySceneDetect Documentation", + author, + "PySceneDetect", + "Python API and `scenedetect` command reference.", + "Miscellaneous", + ), ] +# -- Theme ------------------------------------------------- -# -- Extension configuration ------------------------------------------------- +# TODO: Consider switching to sphinx_material. -html_theme = 'alabaster' +html_theme = "alabaster" html_theme_options = { - 'sidebar_width': '235px', - 'description': 'CLI/API Reference Manual [v0.5]', - 'show_relbar_bottom': True, - 'show_relbar_top': False, - - 'github_user': 'Breakthrough', - 'github_repo': 'PySceneDetect', - 'github_type': 'star', - - 'tip_bg': '#f0f6fa', - 'tip_border': '#c2dcf2', - 'hint_bg': '#f0faf0', - 'hint_border': '#d3ebdc', - 'warn_bg': '#f5ebd0', - 'warn_border': '#f2caa2', - 'attention_bg': '#f5dcdc', - 'attention_border': '#ffaaaa', - 'logo': 'pyscenedetect_logo.png', - 'logo_name': False, - #'logo_name': True, + "sidebar_width": "235px", + "description": f"Version: [{release}]", + "show_relbar_bottom": True, + "show_relbar_top": False, + "github_user": "Breakthrough", + "github_repo": "PySceneDetect", + "github_type": "star", + "tip_bg": "#f0f6fa", + "tip_border": "#c2dcf2", + "hint_bg": "#f0faf0", + "hint_border": "#d3ebdc", + "warn_bg": "#f5ebd0", + "warn_border": "#f2caa2", + "attention_bg": "#f5dcdc", + "attention_border": "#ffaaaa", + "logo": "pyscenedetect_logo.png", + "logo_name": False, } diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index ce08d946..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,34 +0,0 @@ - - -###   Bug Reports - -Bugs and issues with (as well as feature requests for) PySceneDetect are mainly handled through [the issue tracker on Github](https://github.com/Breakthrough/PySceneDetect/issues). If you run into any bugs while using PySceneDetect, please feel free to [create a new issue](https://github.com/Breakthrough/PySceneDetect/issues/new). Provide as much detail as you can - include an example that clearly demonstrates the problem (if possible), and make sure to include any/all relevant program output or error messages. - -When submitting bug reports, please add the command-line options `-v debug -l BUG_REPORT.txt` to the very beginning of the `scenedetect` command you are using, and attach the generated `BUG_REPORT.txt` file. - -Before opening a new issue, please do [search for any existing issues](https://github.com/Breakthrough/PySceneDetect/issues?q=) (both open and closed) which might report similar issues/bugs to avoid creating duplicate entries. If you do find a duplicate report, feel free to add any additional information you feel may be relevant. - - -###   Contributing - -The development of PySceneDetect is done on the Github Repo, guided by [the feature roadmap](features.md). Code you wish to submit should be attached to a dedicated entry in [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=) (with the appropriate tags for bugfixes, new features, enhancements, etc...), and allows for easier communication regarding development structure. Feel free to create a new entry if required, as some planned features or bugs/issues may not yet exist in the tracker. - -All submitted code should be linted with pylint, and follow the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) as closely as possible. Also, ensure that you search through [all existing issues](https://github.com/Breakthrough/PySceneDetect/issues?q=) (both open and closed) beforehand to avoid creating duplicate entries. - -Note that PySceneDetect is released under the BSD 3-Clause license, and submitted code should comply with this license (see [License & Copyright Information](copyright.md) for details). - - -###   List of Contributors - -The following list details some contributors people have made to the PySceneDetect project. In no way is this list complete, nor is the list maintained in any particular order. In addition to those listed below, a significant number of other contributors have greatly helped the development of PySceneDetect by reporting defects/bugs and providing adequate information in order to fix these issues. - -A full list of contributions to the PySceneDetect source code [can be found here](https://github.com/Breakthrough/PySceneDetect/graphs/contributors). A full list of contributions to both PySceneDetect with respect to bug reports and fixes/pull requests can be derived from looking at the list of [all issues](https://github.com/Breakthrough/PySceneDetect/issues?utf8=%E2%9C%93&q=is%3Aissue) and [all pull request](https://github.com/Breakthrough/PySceneDetect/pulls?utf8=%E2%9C%93&q=is%3Apr+). - -In addition to those mentioned below, thank you to *everyone* who has submitted an issue, bug report, and/or pull request (see the links above for complete lists), as well as for those who continue to help the ongoing development of PySceneDetect. Your contributions continue to improve PySceneDetect, highlight the assets and talents of the FOSS community, and help to make the project's goal of being the most accurate scene detection program/library become a reality. - - * [@elcombato](https://github.com/elcombato) - improvement of video processing performance due to reduced memory copy operations - * [@marcelluzs](https://github.com/marcelluzs) - improvement of video processing performance when using the frame-skipping feature - * [@Hellowlol](https://github.com/Hellowlol) - improvements to software architecture and API development - * [@bubalazi](https://github.com/bubalazi) - performance improvements to detect-content and detect-threshold algorithms - * [@r1b](https://github.com/r1b) - proof-of-concept implementation of detect-histogram algorithm - diff --git a/docs/download.md b/docs/download.md deleted file mode 100644 index b64ec7ec..00000000 --- a/docs/download.md +++ /dev/null @@ -1,98 +0,0 @@ - -# Obtaining PySceneDetect - -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 [Installing Dependencies](#installing-dependencies) section below. - - -## Download - -### Windows Standalone (64-bit Only)   - -
-

Latest Release: v0.4 [OLD]

-

  Release Date:  January 14, 2017

-  Installer  (recommended)        Portable  .zip        Getting Started -
- -The Windows distribution of PySceneDetect is bundled with all required dependencies. After installation, you can call PySceneDetect from any terminal/command prompt by typing `scenedetect`. Open a new command prompt (`cmd.exe`) and try running `scenedetect --version` to verify that everything was installed correctly. If using the portable distribution, you need to run the command from the location of the extracted files, where the `scenedetect.exe` executable is. - - -### Python Installer (All Platforms, Requires Python)       - - -
-

Latest Release: v0.5

-

  Release Date:  August 31, 2018

-  Source  .zip        Source  .tar.gz        Installation        Getting Started -
- -To install PySceneDetect using `pip`, make sure you have the appropriate [system requirements](#installing-dependencies) installed **before** installing the `scenedetect` package: -```md -pip install scenedetect -``` - -Otherwise, if installing from source, download and extract the latest release to a location of your choice, and make sure you have the appropriate [system requirements](#installing-dependencies) installed before continuing. PySceneDetect can be installed by running the following command in the location of the extracted files (don't forget `sudo`): - -```md -python setup.py install -``` - -After installation, you can call PySceneDetect from any terminal/command prompt by typing `scenedetect` (try running `scenedetect version`, or `scenedetect --version` in v0.4 and prior, to verify that everything was installed correctly). - - ------------------------------------------------- - - -## Installation - -Start by downloading the latest release of PySceneDetect and extracting it to a location of your choice. Then, follow the instructions below under [Installing Dependencies](#installing-dependencies) to ensure you have all the system requirements. Finally, run the commands in [Installing PySceneDetect](#installing-pyscenedetect) to install the program, allowing you to run the `scenedetect` command from any terminal/command prompt. - -Note that if you are using a Windows distribution (i.e. you used the installer, or downloaded the portable .zip version), you do not need to install any dependencies on your computer, they are bundled with PySceneDetect. - - -### Installing Dependencies - -PySceneDetect requires [Python 2 or 3](https://www.python.org/) and the following third-party software: - - - [OpenCV](http://opencv.org/) (compatible with both 2.X or 3.X), and the OpenCV `cv2` Python module - - [Numpy](http://sourceforge.net/projects/numpy/), Python module - - [tqdm](https://github.com/tqdm/tqdm), optional. Used to show progress bar and estimated time remaining (can usually install via `pip install tqdm`). - -For video splitting support, you also need: - - - [ffmpeg](https://ffmpeg.org/download.html), part of mkvtoolnix, command-line tool, required to split video files in precise/high-quality mode (`split-video` or `split-video -h/--high-quality`) - - [mkvmerge](https://mkvtoolnix.download/), part of mkvtoolnix, command-line tool, required to split video files in copy mode (`split-video -c/--copy`) - - -
-

  Additionally, 64-bit Windows users installing PySceneDetect from source can download ffmpeg.exe and mkvmerge.exe from here.

After extracting the files, the executables can be placed same folder as the scenedetect.exe file created after running python setup.py install, or somewhere else in your PATH variable. The scenedetect.exe file is usually installed in the folder C:\PythonXY\Scripts, where XY is your Python version (e.g. 27, 36). -
- - -The `ffmpeg` and/or `mkvmerge` command must be available system wide (e.g. in a directory in PATH, so it can be used from any terminal/console by typing the command), or alternatively, placed in the same directory where PySceneDetect is installed. - -You can [click here](http://breakthrough.github.io/Installing-OpenCV/) for a quick guide (OpenCV + Numpy on Windows & Linux) on installing the latest versions of OpenCV/Numpy on [Windows (using pre-built binaries)](http://breakthrough.github.io/Installing-OpenCV/#installing-on-windows-pre-built-binaries) and [Linux (compiling from source)](http://breakthrough.github.io/Installing-OpenCV/#installing-on-linux-compiling-from-source). If the Python module that comes with OpenCV on Windows is incompatible with your system architecture or Python version, [see this page](http://www.lfd.uci.edu/~gohlke/pythonlibs/#opencv) to obtain a pre-compiled (unofficial) module. - -Note that some Linux package managers still provide older, dated builds of OpenCV (pre-3.0). PySceneDetect is compatible with both versions, but if you want to ensure you have the latest version, it's recommended that you [build and install OpenCV from source](http://breakthrough.github.io/Installing-OpenCV/#installing-on-linux-compiling-from-source) on Linux. - -To ensure you have all the requirements installed, open a `python` interpreter, and ensure you can run `import numpy` and `import cv2` without any errors. For video splitting support, also and ensure you can run the `ffmpeg` and/or `mkvmerge` from a terminal/console. - -Once this is done, you're ready to install PySceneDetect. - - -### Installing PySceneDetect - -Go to the folder you extracted the PySceneDetect source code to, and run the following command (may require root): - -```md -python setup.py install -``` - -Once finished, PySceneDetect will be installed, and you should be able to run the `scenedetect` command. To verify that everything was installed properly, try calling the following command: - -```md -scenedetect version -``` - -To get familiar with PySceneDetect, try running `scenedetect help`, or continue onwards to the [Getting Started: Basic Usage](examples/usage.md) section. If you encounter any runtime errors while running PySceneDetect, ensure that you have all the required dependencies listed in the System Requirements section above (again, 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. - diff --git a/docs/examples/usage-example.md b/docs/examples/usage-example.md deleted file mode 100644 index 574c18dc..00000000 --- a/docs/examples/usage-example.md +++ /dev/null @@ -1,94 +0,0 @@ - -# Example: Detecting and Splitting Scenes in Movie Clip - -As a concrete example to become familiar with PySceneDetect, let's use the following short clip from the James Bond movie, GoldenEye (Copyright © 1995 MGM): - -[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/goldeneye/goldeneye.mp4) (may have to right-click and save-as, put the video in your working directory as `goldeneye.mp4`). We will first demonstrate using the default parameters, then how to find the optimal threshold/sensitivity for a given video, and lastly, using the PySceneDetect output to split the video into individual scenes/clips. - - -## Content-Aware Detection with Default Parameters - -In this case, we want to split this clip up into each individual scene - at each location where a fast cut occurs. This means we need to use content-aware detecton mode (`-d content`). Using the following command, let's run PySceneDetect on the video using the default threshold/sensitivity: - -```rst -scenedetect --input goldeneye.mp4 detect-content list-scenes save-images -``` - -Running the above command, in the working directory, you should see a file `goldeneye.scenes.csv`, as well as thumbnails for the start/middle/end of each scene as `goldeneye-XXXX-00/01.jpg` (the output directory can be specified with the `-o/--output` option after the `save-images` command, or after `scenedetect` to specify the output for all files). The results should appear as follows: - - -| Scene # | Start Time | Preview | -| ------------ | ------------- | ------------- | -| 1 | 00:00:03.502 | | -| 2 | 00:00:04.144 | | -| 3 | 00:00:04.144 | | -| 4 | 00:00:04.144 | | -| 5 | 00:00:04.144 | | -| 6 | 00:00:04.144 | | -| 7 | 00:00:04.144 | | -| 8 | 00:00:04.144 | | -| 9 | 00:00:04.144 | | -| 10 | 00:00:04.144 | | -| 11 | 00:00:04.144 | | -| 12 | 00:00:04.144 | | -| 13 | 00:00:04.144 | | -| 14 | 00:00:04.144 | | -| 15 | 00:00:04.144 | | -| 16 | 00:00:04.144 | | -| 17 | 00:00:04.144 | | -| 18 | 00:00:04.144 | | -| 19 | 00:00:04.144 | | -| 20 | 00:00:04.144 | | - - -Note that this is *almost* perfect - however, one of the scene cuts/breaks in scene 17 was not detected. We will now generate a statistics file for the `goldeneye.mp4` video to determine the optimal detection threshold (`--threshold 27` ends up being the optimal value for `goldeneye.mp4` when using `detect-content`, versus the default value of `30`). Finally, we will use the output from PySceneDetect to split the original video into individual files/clips. - - -## Finding Optimal Threshold/Sensitivity Value - -We now know that a threshold of `30` does not work in all cases for our video, as per scene 17 detected above (note the last image is from a different scene): - - - -We can determine the proper threshold in this case by generating a statistics file (with the `-s` / `--stats` option) for the video `goldeneye.mp4`, and looking at the behaviour of the values where we expect the scene break/cut to occur in scene 17: - -scenedetect --input goldeneye.mp4 --stats goldeneye.stats.csv detect-content list-scenes save-images - -After examining the file and determining an optimal value of 27 for `detect-content`, we can set the threshold for the detector via: - -scenedetect --input goldeneye.mp4 --stats goldeneye.stats.csv detect-content --threshold 27 list-scenes save-images - -Note that specifying the same `--stats` file again will make parsing the scenes significantly quicker, as the frame metrics stored in this file are re-used as a cache instead of computing them again. Finally, our updated scene list appears as follows (similar entries skipped for brevity): - - -| Scene # | Start Time | Preview | -| ------------ | ------------- | ------------- | -| ... | ... | ... | -| 16 | 00:00:04.144 | | -| 17 | 00:00:04.144 | | -| 18 | 00:00:04.144 | | -| 19 | 00:00:04.144 | | -| 20 | 00:00:04.144 | | -| 21 | 00:00:04.144 | | - - -Now the missing scene (scene number 18, in this case) has been detected properly, and our scene list is larger now due to the added cuts. - - -## Splitting/Cutting Video into Clips - -The last step to automatically split the input file into clips is to specify the `split-video` command. This will pass a list of the detected scene timecodes to `ffmpeg` if installed, splitting the input video into scenes. - -You may also want to use the `-c/--copy` option to ensure that no re-encoding is performed (using `mkvmerge` instead), at the expense of frame-accurate scene cuts, since when copying, cuts can sometimes only be generated on keyframes. You can also pass the `-h/--high-quality` option to ensure the output videos are visually identical to the input (at the expense of longer processing time and greater filesize). - -Thus, to generate a sequence of files `goldeneye-scene-001.mp4`, `goldeneye-scene-002.mp4`, `goldeneye-scene-003.mp4`..., our full command becomes: - - -```rst -scenedetect -i goldeneye.mp4 -o output_dir detect-content -t 27 list-scenes save-images split-video -``` - -The scene number `-001` will be added to the output filename automatically. - diff --git a/docs/examples/usage-python.md b/docs/examples/usage-python.md deleted file mode 100644 index 6da582b8..00000000 --- a/docs/examples/usage-python.md +++ /dev/null @@ -1,92 +0,0 @@ - -# PySceneDetect Python Interface - -In addition to being used from the command line, or through the GUI, PySceneDetect can be used in Python directly - allowing easy integration into other applications/scripts, or interactive use through a Python REPL/notebook. - - -## PySceneDetect API Reference - -**The complete PySceneDetect Python API Reference can be found in the [PySceneDetect Manual](http://pyscenedetect-manual.readthedocs.io/), located at [pyscenedetect-manual.readthedocs.io/](http://pyscenedetect-manual.readthedocs.io/)**. - - -## Example - -The following short Python program shows the general usage style of how to detect scenes using PySceneDetect. This shows how to open a video (or videos), save/load stats to/from a statsfile (CSV), perform scene detection (using the `ContentDetector`), and print a list of detected scenes to the terminal/console. - -```python -from __future__ import print_function -import os - -import scenedetect -from scenedetect.video_manager import VideoManager -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.stats_manager import StatsManager -from scenedetect.detectors import ContentDetector - -STATS_FILE_PATH = 'testvideo.stats.csv' - -def main(): - - # Create a video_manager point to video file testvideo.mp4. Note that multiple - # videos can be appended by simply specifying more file paths in the list - # passed to the VideoManager constructor. Note that appending multiple videos - # requires that they all have the same frame size, and optionally, framerate. - video_manager = VideoManager(['testvideo.mp4']) - stats_manager = StatsManager() - scene_manager = SceneManager(stats_manager) - # Add ContentDetector algorithm (constructor takes detector options like threshold). - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - - try: - # If stats file exists, load it. - if os.path.exists(STATS_FILE_PATH): - # Read stats from CSV file opened in read mode: - with open(STATS_FILE_PATH, 'r') as stats_file: - stats_manager.load_from_csv(stats_file, base_timecode) - - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 20.0 # 00:00:20.000 - # Set video_manager duration to read frames from 00:00:00 to 00:00:20. - video_manager.set_duration(start_time=start_time, end_time=end_time) - - # Set downscale factor to improve processing speed (no args means default). - video_manager.set_downscale_factor() - - # Start video_manager. - video_manager.start() - - # Perform scene detection on video_manager. - scene_manager.detect_scenes(frame_source=video_manager, - start_time=start_time) - - # Obtain list of detected scenes. - scene_list = scene_manager.get_scene_list(base_timecode) - # Like FrameTimecodes, each scene in the scene_list can be sorted if the - # list of scenes becomes unsorted. - - print('List of scenes obtained:') - 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(),)) - - # We only write to the stats file if a save is required: - if stats_manager.is_save_required(): - with open(STATS_FILE_PATH, 'w') as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode) - - finally: - video_manager.release() - -if __name__ == "__main__": - main() -``` - - -## Scene Detection in a Python REPL - -PySceneDetect can be used interactively as well. One way to get familiar with this is to type the above example into a Python REPL line by line, viewing the output as you run through the code and making sure you understand the output/results. In the future, functions may be added to preview the scene boundaries graphically using OpenCV's GUI functionality, to allow interactive use of PySceneDetect from the command-line without launching the full GUI. - diff --git a/docs/examples/usage.md b/docs/examples/usage.md deleted file mode 100644 index f3dbfc41..00000000 --- a/docs/examples/usage.md +++ /dev/null @@ -1,191 +0,0 @@ - -# Usage (Command Line) - -This page outlines the most commonly used command-line options for using PySceneDetect. Basic usage of PySceneDetect (`scenedetect`) is: - -```rst -scenedetect [global options] [commands + command options] -``` - -You can also print the usage/help manual of PySceneDetect with the `help` command: - -```rst -scenedetect help -``` - - -## PySceneDetect Manual - -**The complete PySceneDetect Command-Line Interface (CLI) Reference can be found in the [PySceneDetect Manual](http://pyscenedetect-manual.readthedocs.io/), located at [pyscenedetect-manual.readthedocs.io/](http://pyscenedetect-manual.readthedocs.io/)**. - - -## Quick Example - -For example, to perform content-aware scene detection (`detect-content`) on a video (`--input my_video.mp4`), listing all scenes in the console/to a CSV file (`list-scenes`), *and* saving each detected scene as image files (`save-images`): - -```rst -scenedetect --input my_video.mp4 --output my_video_scenes --stats my_video.stats.csv detect-content list-scenes save-images -``` - -Here we also specified the output directory (`--output my_video_scenes`) as well as to generate/use a stats file (`--stats my_video.stats.csv`), which can be used to determine/tweak the various detection algorithm options. - -Note that there is no particular order to each command, the only requirement is that all global program options appear before the first command, and all options for a given command immediately follow it. For example, in addition to saving images for each scene, we can also split the input video (`split-video`) or tweak detection parameters (using the stats file from the previous call as well will speed up scene detection time significantly): - -```rst -scenedetect --input my_video.mp4 --output my_video_scenes --stats my_video.stats.csv detect-content list-scenes save-images split-video -``` - -## Getting Started - -To show a summary of all global options/arguments, and a list of commands: - -```rst -scenedetect help -``` - -You can also type `help command` where `command` is a specific command (e.g. `list-scenes`, `detect-content`). Also, to show a complete help listing for every command: - -```rst -scenedetect help all -``` - -To start off, let's perform content-aware scene detection on a video `my_video.mp4` ([example](usage-example.md)) with the default threshold, and display a list of detected scenes: - -```rst -scenedetect --input my_video.mp4 detect-content list-scenes -``` - -Next, the same, but also split the input video into individual clips (starting from `my_video-Scene-001.mp4`): - -```rst -scenedetect --input my_video.mp4 detect-content list-scenes split-video -``` - -The `split-video` command requires either `ffmpeg` or `mkvmerge` to be available, depending on the options used. By default `ffmpeg` is used unless the `-c`/`--copy` argument is specified. This ensures that each video starts and ends *exactly* at the timecodes PySceneDetect finds. You can also override the codec arguments manually: - -```rst -scenedetect --input my_video.mp4 detect-content list-scenes split-video --ffmpeg-args "-c:v libx264 -c:a aac" -``` - -You can also supply the `-h` / `--high-quality` option to the `split-video` command, which re-encodes the output videos with better quality when splitting the video into scenes. Optionally, you can also specify the x264 `-p`/`--preset` and `-crf`/`--rate-factor` (call `scenedetect help split-video` for details). - -PySceneDetect can also copy the input video stream at the given scene cuts instead of re-encoding if you supply the `-c` / `--copy` option, which uses `mkvmerge` and is fairly quick. However, in some cases, this does not produce accurate output videos, as some video formats only allow splitting on keyframes. This is especially apparent when some of the scenes are very short in length. - -```rst -scenedetect --input my_video.mp4 detect-content list-scenes split-video --copy -``` - -In order to effectively use PySceneDetect, you should become familiar with the basic command line options described below - especially the scene detection method/algorithm (`detect-content` and `detect-threshold`) and the threshold/sensitivity value for each (both commands have an optional `-t` / `--threshold` value that can be set). These are described in the following section with respect to each detection method. - -Lastly, note that descriptions for all command-line arguments, as well as their default values, can be obtained by running PySceneDetect with the `help` command, `help [command]` for a specific command, or `help all` for a complete help and command listing. - - -## Detection Methods - -There are two main detection methods PySceneDetect uses: `detect-threshold` (comparing each frame to a set black level, useful for detecting cuts and fades to/from black), and `detect-content` (compares each frame sequentially looking for changes in content, useful for detecting fast cuts between video scenes, although slower to process). Each mode has slightly different parameters, and is described in detail below. - -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-content` mode. 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 paramters - specifically, the proper threshold value. - - -### Content-Aware Detection Mode & Stats Files - -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 default threshold value (`-t` / `--threshold`), which is good for a first try when using content-aware mode (`detect-content`), is `30`. Thus: - -```rst -scenedetect -i my_video.mp4 -s my_video.stats.csv list-scenes detect-content -``` - -Is the equivalent of: - -```rst -scenedetect -i my_video.mp4 -s my_video.stats.csv list-scenes detect-content -t 30 -``` - -Remember to supply the `list-scenes` command, after all main program options, to show which scenes were generated, as well as optionally the `save-images` command to save images for each scene, and/or the `split-video` command to split the input video automatically. - -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. 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). - -You can supply the same stats file in subsequent calls to `scenedetect` with different threshold values to speed the processing time up significantly when experimenting with different values on the **same** video (or set of videos). You *can* use multiple detectors with the same stats file, so long as you supply the *exact* same `-i` / `--input` video file(s) each time. - -*Remember*: once a stats file is created, it can only be used with the **same** input video(s). If you want to process a different input video (or set of videos), change the name of the stats file supplied to `-s` / `--stats`, or delete the existing stats file on disk. - - -### Threshold-Based Detection Mode - -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 -scenedetect -i my_video.mp4 -s my_video.stats.mp4 detect-threshold -``` - -```rst -scenedetect -i my_video.mp4 -s my_video.stats.mp4 detect-threshold -t 12 -p 95 -``` - -For most videos, the minimum percentage (`-p` / `--min-percent`) should always be at *least* 90% (`-p 90`, the default value is `95`). Also, using values for threshold less than `8` may cause problems with some videos, especially those encoded at lower bitrates or with limited dynamic range. - -As with `detect-content`, the optimal threshold can be determined by generating a statsfile (`-s`), opening it with a spreadsheet editor (e.g. Excel), and examining the `delta_rgb` column. These values represent the average intensity of the pixels for that particular frame (taken by averaging the R, G, and B values over the whole frame). The threshold value should be set so that the average intensity of most frames in content scenes lie above the threshold value, and scenes where scene changes/breaks occur should fall *under* the threshold value (thus triggering a scene change). - -## Actions / Commands - -After setting the detection method(s), there are several commands that can be used. Type `scenedetect help [command]` for help/arguments of a specific command listed below, or see the [full CLI reference](../reference/command-line-params.md) for details. - - - `time`: Used to set input video duration/length or start/end time (discussed below). - - `list-scenes`: Print and save a list of all scenes in table and CSV format. - - `split-video`: Split input video into scenes automatically. - - `save-images`: Save images from the video for each scene. - - `help`: Print help for PySceneDetect or a particular command. No processing is done if present. - - `version`: Print PySceneDetect release version. No processing is done if present. - - `about`: Print PySceneDetect license agreement and application information. No processing is done if present. - - -## Seeking, Duration, and Setting Start / Stop Times - -Specifying the `time` command allows control over what portion of the video PySceneDetect processes. The `time` command accepts three options: start time (`-s` / `-start`), end time (`-e` / `-end`), and duration (`-d` / `--duration`). Specifying both end time and duration is redundant, and in this case, duration overrides end time. Timecodes can be given in three formats: exact frame number (e.g. `12345`), number of seconds followed by `s` (e.g. `123s`, `123.45s`), or standard format (HH:MM:SS[.nnn], e.g. `12:34:56`, `12:34:56.789`). - -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 -scenedetect -i my_video.mp4 time --start 00:05:00 --end 00:06:30 detect-content -``` - -```rst -scenedetect -i my_video.mp4 time --start 300s --end 390s detect-content -``` - -```rst -scenedetect -i my_video.mp4 time --start 300s --duration 90s detect-content -``` - -```rst -scenedetect -i my_video.mp4 time --start 300s --duration 2700 detect-content -``` - -This demonstrates the different timecode formats, interchanging end time with duration and vice-versa, and precedence of setting duration over end time. - - -## Saving Image Previews of Detected Scenes - -PySceneDetect can automatically save the beginning and ending frame of each detected scene by using the `save-images` command. If present, the first and last frames of each scene will be saved in the current working directory, using the filename of the input video. - -Files marked `00` represent the starting frame of the scene, and those marked `01` represent the last frame (e.g. `testvideo.mp4.Scene-4-01.jpg`). By default, two images are generated. - -Coming soon: If more are specified via the `-n` flag, they will start from `00` (the first frame) and be evenly spaced throughout the scene until the last frame, which will be numbered `N-1`. - - -## Improving Processing Speed/Performance - -The following arguments are global program options, and need to be applied before any commands (e.g. `detect-content`, `list-scenes`). They can be used to achieve performance gains for some source material with a variable loss of accuracy. - -Assuming the input video is of a high enough resolution, a significant performance gain can be achieved by sub-sampling (down-scaling) the input image by a specific integer factor (2x, 3x, 4x, 5x...). This is applied automatically to some degree based on the input video size, but can be overriden manually with the `-d` / `--downscale` option. - -This factor represents how many pixels are "skipped" in both the x- and y- directions, effectively down-scaling the image (using nearest-neighbor sampling) by the factor specified (the new resolution being `W/factor x H/factor` if the old resolution is `W x H`). - -Another method that can be used to gain a performance boost is frame skipping. This method, however, severely reduces frame-accurate scene cuts, so it should only be used with high FPS material (ideally > 60 FPS), at low values (try not to exceed a value of `1` or `2` if using `-fs` / `--frame-skip`), in cases where this is acceptable. Using the frame skip option also disallows the use of a stats file, which offsets the speed gain if the same video needs to be processed multiple times (e.g. to determine the optimal threshold). - -The option still remains, however, for the set of cases where it is still required. For example, if we skip every other frame (e.g. using `--frame-skip 1`), the processing speed should roughly double. - -If set too large, enough frames may be skipped each time that the threshold is met during every iteration, continually triggering scene changes. This is because frame skipping essentially raises the threshold between frames in the same scene (making them more likely to appear as *cuts*) while not affecting the threshold between frames of different scenes. - -This makes the two harder to distinguish, and can cause additional false scene cuts to be detected. While this can be compensated for by raising the threshold value, this increases the probability of missing a real/true scene cut - thus, the use of the `-fs` / `--frame-skip` option is discouraged. - diff --git a/docs/examples/video-splitting.md b/docs/examples/video-splitting.md deleted file mode 100644 index e9aa7fdc..00000000 --- a/docs/examples/video-splitting.md +++ /dev/null @@ -1,33 +0,0 @@ - -##   Video Splitting Support Requirements - -PySceneDetect can use either `ffmpeg` or `mkvmerge` to split videos automatically. - -By default, when specifying the `split-video` command, `ffmpeg` will be used to split the video. If the `-c`/`--copy` option is also set (e.g. `split-video --copy`), `mkvmerge` will be used to split the video instead. - - -### FFmpeg - -You can download `ffmpeg` from: [https://ffmpeg.org/download.html](https://ffmpeg.org/download.html) - -Note that Linux users should use a package manager (e.g. `sudo apt-get install ffmpeg`). Windows users may require additional steps in order for PySceneDetect to detect `ffmpeg` - see the section Manually Enabling `split-video` Support below for details. - - -### mkvmerge - -You can download and install `mkvmerge` as part of the mkvtoolnix package from: -[https://mkvtoolnix.download/downloads.html](https://mkvtoolnix.download/downloads.html) - -Note that Windows users should use the installer/setup, and Linux users should use their system package manager, otherwise PySceneDetect may not be able to find `mkvmerge`. If this is the case, see the section below to enable support for the `split-video --copy` command manually. - - -## Manually Enabling `split-video` Support - -If PySceneDetect cannot find the respective tool installed on your system, you have three options: - - 1. Place the tool in the same location that PySceneDetect is installed (e.g. copy and paste mkvmerge.exe into the same place scenedetect.exe is located). This is the easiest solution for most users. - - 2. Add the directory where you installed ffmpeg/mkvmerge to your system's PATH environment variable, ensuring that you can use the ffmpeg/mkvmerge command from any terminal/command prompt. This is the best solution for advanced users. - - 3. Place the tool in a location already in your system's PATH variable (e.g. C:/Windows). This is not recommended, but may be the only solution on systems without administrative rights. - diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index bc23b243..00000000 --- a/docs/features.md +++ /dev/null @@ -1,76 +0,0 @@ - -## Overview of Features - -
-

  Content-Aware Scene Detection

   Detects breaks in-between content, not only when the video fades to black (although a threshold mode is available as well for those cases). -
- -
-

  Compatible With Many External Tools

   The detected scene boundaries/cuts can be exported in a variety of formats, with the default type (comma-separated HH:MM:SS.nnn values) being ready to copy-and-paste directly into other tools (such as ffmpeg, mkvmerge, etc...) for splitting and/or re-encoding the video. -
- -
-

  Statistical Video Analysis

   Can output a spreadsheet-compatible file for analyzing trends in a particular video file, to determine the optimal threshold values to use with specific scene detection methods/algorithms. -
- -
-

  Extendible and Embeddable

   Written in Python, and designed with an easy-to-use and extendable API, PySceneDetect is ideal for embedding into other programs, or to implement custom methods/algorithms of scene detection for specific applications (e.g. analyzing security camera footage). -
- - ----------------- - - -### Features in Current Release - - - 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 (`--stats/-s statsfile.csv`) - - output-suppression (quiet) mode for better automation with external scripts/programs (`-v quiet`) - - user-selectable subsampling for improved performance (`-d/--downscale`) - - user-selectable frame skipping for improved performance (`-fs`, not recommended) - - save an image of the first and last frame of each detected scene via the `save-images` command - - ability to specify starting/ending times via `time` command (`--start/-s` and `--end/-e`), and/or set duration for processing (`--duration/-d`) - - user-definable fade bias to shift scenes between fade in/out points (threshold mode only) - -### List of Scene Detection Methods - - - **threshold scene detection** (`detect-threshold`): analyzes video for changes in average frame intensity/brightness - - **content-aware scene detection** (`detect-content`): based on changes between frames in the HSV color space - -For a detailed explanation of how a particular scene detection method/algorithm works, see the [Scene Detection Method Details Section](reference/detection-methods.md) in the Documentation & Reference. - - ----------------- - - -## Version Roadmap - -Specific issues/features that are queued up for the very next release will have [the `backlog` tag](https://github.com/Breakthrough/PySceneDetect/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+backlog%22), and issues/features being worked on will have [the `status: in progress` tag](https://github.com/Breakthrough/PySceneDetect/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+in+progress%22). Also note that bug reports as well as additional feature requests can be submitted via [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues); read [the Bug Reports and Contributing page](contributing.md) for details. - -

Features in Development for Next Version

- -The following are features being planned or developed for the release following v0.5 (which will be, depending on community feedback, v0.5.1, v0.6, or v1.0): - - - support for using multiple `--input` videos and the `split-video` command **without** the `-c`/`--copy` flag [ [#71] ](https://github.com/Breakthrough/PySceneDetect/issues/71) - - optional suppression of short-length flashes/bursts of light [ [#35] ](https://github.com/Breakthrough/PySceneDetect/issues/35) - - export scenes in HTML format [ [#17] ](https://github.com/Breakthrough/PySceneDetect/issues/17) - - automatic threshold detection for the current scene detection methods (can simply be an ouptut message indicating "Predicted Best Threshold: X") - -

Planned Features for Future Releases

- -The following are features being planned or developed for future releases of PySceneDetect: - - - colour histogram-based scene detection algorithm in the HSV/HSL colourspace [ [#53] ](https://github.com/Breakthrough/PySceneDetect/issues/53) - - [perceptual hash](https://en.wikipedia.org/wiki/Perceptual_hashing) based scene detection - - improve robustness of content-aware detection by combining with edge detection (similar to MATLAB-based scene change detector) - - adaptive bias for fade in/out interpolation - - multithreaded implementation of detection algorithms for improved performance - - GUI for easier previewing and threshold setting (will be GTK+ 3 based via PyGObject) - - export scenes in chapter/XML format - - additional timecode formats - diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py new file mode 100644 index 00000000..68542292 --- /dev/null +++ b/docs/generate_cli_docs.py @@ -0,0 +1,284 @@ +# Generate formatted CLI documentation for PySceneDetect. +# +# Inspired by sphinx-click: https://github.com/click-contrib/sphinx-click +# +# 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. + +Run from main repo folder as working directory.""" + +import inspect +import os +import re +import sys +import typing as ty +from dataclasses import dataclass + +# Add parent folder to path so we can resolve `scenedetect` imports. +currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +parentdir = os.path.dirname(currentdir) +sys.path.insert(0, parentdir) +# Third-party imports (must follow sys.path mutation above). +import click # noqa: E402 + +from scenedetect._cli import scenedetect # noqa: E402 + +StrGenerator = ty.Generator[str, None, None] + +INDENT = " " * 4 + +PAGE_SEP = "*" * 72 +TITLE_SEP = "=" * 72 +HEADING_SEP = "-" * 72 + +OPTION_HELP_OVERRIDES = { + "scenedetect": { + "config": "Path to config file. See :ref:`config file reference ` for details." + }, +} + +TITLE_LEVELS = ["*", "=", "-"] + +INFO_COMMANDS = ["help", "about", "version"] + +INFO_COMMAND_OVERRIDE = """ +.. _command-help: + +``help``, ``version``, and ``about`` +======================================================================= + +.. program:: scenedetect help + +``scenedetect --help`` will print PySceneDetect options, commands, and examples. You can also specify: + + * ``scenedetect [command] --help`` to show options and examples *for* a command or detector + + * ``scenedetect help`` command to print full reference of all options, commands, and examples + +.. program:: scenedetect version + +``scenedetect version`` prints the version of PySceneDetect that is installed, as well as system dependencies. + +.. program:: scenedetect about + +``scenedetect about`` prints PySceneDetect copyright, licensing, and redistribution information. This includes a list of all third-party software components that PySceneDetect uses or interacts with, as well as a reference to the license and copyright information for each component. +""" + + +def patch_help(s: str, commands: list[str]) -> str: + # Patch some TODOs still not handled correctly below. + pos = 0 + while True: + pos = s.find("global option :option:", pos) + if pos < 0: + break + pos = s.find("<-", pos) + assert pos > 0 + s = s[: pos + 1] + "scenedetect " + s[pos + 1 :] + + for command in [command for command in commands if command not in INFO_COMMANDS]: + + def add_link(_match: re.Match, command: str = command) -> str: + return f":ref:`{command} `" + + s = re.sub(f"``{command}``(?!\\n)", add_link, s) + return s + + +def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator: + yield "\n" + if level == 0: + yield TITLE_LEVELS[level] * len + "\n" + yield s + "\n" + yield TITLE_LEVELS[level] * len + "\n\n" + + +@dataclass +class ReplaceWithReference: + range: tuple[int, int] + ref: str + ref_type: str + + +def transform_backquotes(s: str) -> str: + return s.replace("``", "`").replace("`", "``") + + +def add_backquotes(match: re.Match) -> str: + return f"``{match.string[match.start() : match.end()]}``" + + +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(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 f":option:`{option} <{cross_ref}>`" + else: + return add_backquotes(s) + + return _add_backquotes + + +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 = f'"{default}"' + return (s, default) + + +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(r"-\w/--\w[\w-]*", transform, s) + # --arg=value, --arg=1.2.3, --arg=1,2,3 + s = re.sub(r'-+[\w-]+=[^"\s\)]+(? StrGenerator: + if isinstance(opt, click.Argument): + yield f"\n.. option:: {opt.name}\n" + return + 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 = ( + OPTION_HELP_OVERRIDES[command.name][opt.name] + if command.name in OPTION_HELP_OVERRIDES and opt.name in OPTION_HELP_OVERRIDES[command.name] + else opt.help.strip() + ) + + # TODO: Make metavars link to the option as well. + help, default = extract_default_value(help) + help = transform_add_option_refs(help, flags) + + yield f"\n {help}\n" + if default is not None: + yield f"\n Default: ``{default}``\n" + + +def generate_command_help( + 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 f"\n.. _command-{command.name}:\n" + yield "\n.. program:: %s\n\n" % ( + command.name if parent_name is None else f"{parent_name} {command.name}" + ) + if parent_name: + yield from generate_title(f"``{command.name}``", 1) + + replacements = [ + opt + for opts in [param.opts for param in command.params if hasattr(param, "opts")] + for opt in opts + ] + + help = command.help + help = help.replace( + "Examples:\n", "".join(generate_title("Examples", 0 if not parent_name else 2)) + ) + help = help.replace("\b\n", "") + help = help.format(scenedetect="scenedetect", scenedetect_with_video="scenedetect -i video.mp4") + help = transform_backquotes(help) + help = transform_add_option_refs(help, replacements) + + for line in help.strip().splitlines(): + if line.startswith(INDENT): + indent = line.count(INDENT) + line = line.strip() + yield f"{indent * INDENT}``{line}``\n" if line else "\n" + else: + yield f"{line}\n" + + if command.params: + yield "\n" + yield from generate_title("Options", 0 if not parent_name else 2) + for param in command.params: + yield from format_option(command, param, replacements) + yield "\n" + + +def generate_subcommands(ctx: click.Context, commands: list[str]) -> StrGenerator: + processed = set() + + for info_command in INFO_COMMANDS: + assert info_command in commands + processed.add(info_command) + yield INFO_COMMAND_OVERRIDE + + yield from generate_title("Detectors", 0) + detectors = [command for command in commands if command.startswith("detect-")] + for detector in detectors: + yield from generate_command_help(ctx, ctx.command.get_command(ctx, detector), ctx.info_name) + processed.add(detector) + + yield from generate_title("Commands", 0) + output_commands = [ + command + for command in commands + if (not command.startswith("detect-") and command not in INFO_COMMANDS) + ] + for command in output_commands: + yield from generate_command_help(ctx, ctx.command.get_command(ctx, command), ctx.info_name) + processed.add(command) + + assert set(commands) == processed + + +def create_help() -> tuple[str, list[str]]: + ctx = click.Context(scenedetect, info_name=scenedetect.name) + + 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`` \N{CLAPPER BOARD} Command", level=0), + generate_command_help(ctx, ctx.command), + generate_subcommands(ctx, commands), + ] + lines = [] + for action in actions: + lines.extend(action) + return "".join(lines), commands + + +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()) + + +if __name__ == "__main__": + main() diff --git a/docs/img/pyscenedetect_logo.png b/docs/img/pyscenedetect_logo.png deleted file mode 100644 index 4a119108..00000000 Binary files a/docs/img/pyscenedetect_logo.png and /dev/null differ diff --git a/docs/img/pyscenedetect_logo_small.png b/docs/img/pyscenedetect_logo_small.png deleted file mode 100644 index 5fe84149..00000000 Binary files a/docs/img/pyscenedetect_logo_small.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 53cc8a5d..00000000 --- a/docs/index.md +++ /dev/null @@ -1,26 +0,0 @@ - -PySceneDetect -

Intelligent scene cut detection and video splitting tool.

- -
-

  Latest Release: v0.5 (August 31, 2018)

-  Download        Changelog        Installation        Getting Started -
- -**PySceneDetect** is a command-line application and a Python library for **detecting scene changes in videos**, and **automatically splitting the video into separate clips**. Not only is it free and open-source software (FOSS), but there are several detection methods available ([see Features](features.md)), from simple threshold-based fade in/out detection, to advanced content aware fast-cut detection of each shot. - -PySceneDetect can be used on its own as a stand-alone executable, with other applications as part of a video processing pipeline, or integrated directly into other programs/scripts via the Python API. PySceneDetect is written in Python, and requires the OpenCV and Numpy software libraries. - - -

Examples and Use Cases

- -Here are some of the things people are using PySceneDetect for: - - - splitting home videos or other source footage into individual scenes - - automated detection and removal of commercials from PVR-saved video sources - - processing and splitting surveillance camera footage - - statistical analysis of videos to find suitable "loops" for looping GIFs/cinemagraphs - - academic analysis of film and video (e.g. finding mean shot length) - -Of course, this is just a small slice of what you can do with PySceneDetect, so why not try it out for yourself! The timecode format used by default (`HH:MM:SS.nnnn`) is compatible with most popular video tools, so in most cases the output scene list from PySceneDetect can be directly copied and pasted into another tool of your choice (e.g. `ffmpeg`, `avconv` or the `mkvtoolnix` suite). - diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..1fc92ea0 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,65 @@ + +.. PySceneDetect documentation index file (contains toctree directive). + Copyright (C) 2014 Brandon Castellano. All rights reserved. + +####################################################################### +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` (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:: + + If you see any errors in the documentation, or want to suggest improvements, feel free to raise an issue on `the PySceneDetect issue tracker `_. + +PySceneDetect development happens on Github at `github.com/Breakthrough/PySceneDetect `_. + + +*********************************************************************** +Table of Contents +*********************************************************************** + +======================================================================= +``scenedetect`` Command Reference 🖥️ +======================================================================= + +.. toctree:: + :maxdepth: 2 + :caption: Command-Line Interface: + :name: clitoc + + cli + cli/config_file + cli/backends + + +======================================================================= +``scenedetect`` Python Module 🐍 +======================================================================= + +.. toctree:: + :maxdepth: 2 + :caption: API Documentation: + :name: apitoc + + api + api/detectors + api/output + api/backends + api/common + api/scene_manager + api/detector + api/video_stream + api/stats_manager + api/platform + api/migration_guide + +======================================================================= +Indices and Tables +======================================================================= + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/manual/make.bat b/docs/make.bat similarity index 100% rename from manual/make.bat rename to docs/make.bat diff --git a/docs/other/similar.md b/docs/other/similar.md deleted file mode 100644 index baf8c2d8..00000000 --- a/docs/other/similar.md +++ /dev/null @@ -1,11 +0,0 @@ - -## Alternative Scene Cut/Change Detection Programs - -The following is a list of programs or commands also performing scene cut analysis of some kind on video files. In the future, this may be replaced with a table comparing PySceneDetect's features with these various alternatives. - - - ffmpeg `blackframe` filter ([thanks @tonycpsu](https://github.com/Breakthrough/PySceneDetect/issues/7)) - threshold mode only - - [Shotdetect](http://johmathe.name/shotdetect.html) - appears to be only for *NIX, content mode only - - [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 - -This list is not guaranteed to be complete, although [additions/contributions are most welcome](../contributing.md). - diff --git a/docs/reference/command-line-params.md b/docs/reference/command-line-params.md deleted file mode 100644 index 354f59fe..00000000 --- a/docs/reference/command-line-params.md +++ /dev/null @@ -1,319 +0,0 @@ - -## PySceneDetect CLI Reference - -The PySceneDetect command-line interface is grouped into commands which -can be combined together, each containing its own set of arguments: - -```md -scenedetect ([options]) [command] ([options]) ([...other command(s)...]) -``` - -Where [command] is the name of the command, and ([options]) are the -arguments/options associated with the command, if any. Options -associated with the scenedetect command below (e.g. --input, ---framerate) must be specified before any commands. The order of -commands is not strict, but each command should only be specified once. - -Commands can also be combined, for example, running the 'detect-content' -and 'list-scenes' (specifying options for the latter): - -```md -scenedetect -i vid0001.mp4 detect-content list-scenes -n -``` -A list of all commands is printed below. Help for a particular command -can be printed by specifying 'help [command]', or 'help all' to print -the help information for every command. - -Lastly, there are several commands used for displaying application -version and copyright information (e.g. scenedetect about): - - - `version`: Displays the version of PySceneDetect being used. - - `about`: Displays PySceneDetect license and copyright information. - - -## Global Options - -```md -PySceneDetect Option/Command List: ----------------------------------------------------- - -Usage: scenedetect [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]... - - For example: - - scenedetect -i video.mp4 -s video.stats.csv detect-content list-scenes - - Note that the following options represent [OPTIONS] above. To list the - optional [ARGS] for a particular COMMAND, type `scenedetect help COMMAND`. - You can also combine commands (e.g. scenedetect [...] detect-content save- - images --png split-video). - -Options: - -i, --input VIDEO [Required] Input video file. May be specified - multiple times to concatenate several videos - together. - -o, --output DIR Output directory for all files (stats file, output - videos, images, log files, etc...). - -f, --framerate FPS Force framerate, in frames/sec (e.g. -f 29.97). - Disables check to ensure that all input videos have - the same framerates. - -d, --downscale N Integer factor to downscale frames by (e.g. 2, 3, - 4...), where the frame is scaled to width/N x - height/N (thus -d 1 implies no downscaling). Each - increment speeds up processing by a factor of 4 (e.g. - -d 2 is 4 times quicker than -d 1). Higher values can - be used for high definition content with minimal - effect on accuracy. [default: 2 for SD, 4 for 720p, 6 - for 1080p, 12 for 4k] - -fs, --frame-skip N Skips N frames during processing (-fs 1 skips every - other frame, processing 50% of the video, -fs 2 - processes 33% of the frames, -fs 3 processes 25%, - etc...). Reduces processing speed at expense of - accuracy. [default: 0] - -s, --stats CSV Path to stats file (.csv) for writing frame metrics - to. If the file exists, any metrics will be - processed, otherwise a new file will be created. Can - be used to determine optimal values for various scene - detector options, and to cache frame calculations in - order to speed up multiple detection runs. - -v, --verbosity LEVEL Level of debug/info/error information to show. - Setting to none will suppress all output except that - generated by actions (e.g. timecode list output). - -l, --logfile LOG Path to log file for writing application logging - information, mainly for debugging. Make sure to set - "-il debug" as well if you are submitting a bug - report. - -q, --quiet Suppresses all output of PySceneDetect except for - those from the specified commands. Equivalent to - setting "--info-level none", and overrides the - current info-level, even if --info-level/-il is - specified. - -h, --help Show this message and exit. - -``` - - -## Command List - -```md -Commands: - about Print license/copyright info. - detect-content Perform content detection algorithm on input... - detect-threshold Perform threshold detection algorithm on... - help Print help for command (help [command]). - list-scenes Prints scene list and outputs to a CSV file. - save-images Create images for each detected scene. - split-video Split input video(s) using ffmpeg or... - time Set start/end/duration of input video(s). - version Print version of PySceneDetect. -``` - - -## `time` Command - -```md -PySceneDetect time Command ----------------------------------------------------- -Usage: scenedetect time [OPTIONS] - - Set start/end/duration of input video(s). - - Time values can be specified as frames (NNNN), seconds (NNNN.NNs), or as a - timecode (HH:MM:SS.nnn). For example, to start scene detection at 1 - minute, and stop after 100 seconds: - - time --start 00:01:00 --duration 100s - - 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: - - time --start 0 --end 1000 - -Options: - -s, --start TIMECODE Time in video to begin detecting scenes. TIMECODE - can be specified as exact number of frames (-s 100 - to start at frame 100), time in seconds followed by - s (-s 100s to start at 100 seconds), or a timecode - in the format HH:MM:SS or HH:MM:SS.nnn (-s 00:01:40 - to start at 1m40s). [default: 0] - -d, --duration TIMECODE Maximum time in video to process. TIMECODE format - is the same as other arguments. Mutually exclusive - with --end / -e. - -e, --end TIMECODE Time in video to end detecting scenes. TIMECODE - format is the same as other arguments. Mutually - exclusive with --duration / -d. - -h, --help Show this message and exit. -``` - - -## `detect-content` Command - -```md -PySceneDetect detect-content Command ----------------------------------------------------- -Usage: scenedetect detect-content [OPTIONS] - - Perform content detection algorithm on input video(s). - - detect-content - - detect-content --threshold 27.5 - -Options: - -t, --threshold VAL Threshold value (float) that the delta_hsv frame - metric must exceed to trigger a new scene. - Refers to frame metric delta_hsv_avg in stats - file. [default: 30.0] - -m, --min-scene-len FRAMES Minimum size/length of any scene, in number of - frames. [default: 15] - -h, --help Show this message and exit. -``` - - -## `detect-threshold` Command - -```md -PySceneDetect detect-threshold Command ----------------------------------------------------- -Usage: scenedetect detect-threshold [OPTIONS] - - Perform threshold detection algorithm on input video(s). - - detect-threshold - - detect-threshold --threshold 15 - -Options: - -t, --threshold VAL Threshold value (integer) that the delta_rgb - frame metric must exceed to trigger a new scene. - Refers to frame metric delta_rgb in stats file. - [default: 12] - -m, --min-scene-len FRAMES Minimum size/length of any scene, in number of - frames. [default: 15] - -f, --fade-bias PERCENT Percent (%) from -100 to 100 of timecode skew - for where cuts should be placed. -100 indicates - the start frame, +100 indicates the end frame, - and 0 is the middle of both. [default: 0] - -l, --add-last-scene If set, if the video ends on a fade-out, an - additional scene will be generated for the last - fade out position. - -p, --min-percent PERCENT Percent (%) from 0 to 100 of amount of pixels - that must meet the threshold value in orderto - trigger a scene change. [default: 95] - -b, --block-size N Number of rows in image to sum per iteration - (can be tuned for performance in some cases). - [default: 8] - -h, --help Show this message and exit. -``` - - -## `list-scenes` Command - -```md -PySceneDetect list-scenes Command ----------------------------------------------------- -Usage: scenedetect list-scenes [OPTIONS] - - Prints scene list and outputs to a CSV file. The default filename is - $VIDEO_NAME-Scenes.csv. - -Options: - -o, --output DIR Output directory to save videos to. Overrides global - option -o/--output if set. - -f, --filename NAME Filename format to use for the scene list CSV file. - You can use the $VIDEO_NAME macro in the file name. - [default: $VIDEO_NAME-Scenes.csv] - -n, --no-output-file Disable writing scene list CSV file to disk. If set, - -o/--output and -f/--filename are ignored. - -q, --quiet Suppresses output of the table printed by the list- - scenes command. -``` - - -## `save-images` Command - -```md -PySceneDetect save-images Command ----------------------------------------------------- -Usage: scenedetect save-images [OPTIONS] - - Create images for each detected scene. - -Options: - -o, --output DIR Output directory to save images to. Overrides global - option -o/--output if set. - -f, --filename NAME Filename format, *without* extension, to use when - saving image files. You can use the $VIDEO_NAME, - $SCENE_NUMBER, and $IMAGE_NUMBER macros in the file - name. [default: $VIDEO_NAME- - Scene-$SCENE_NUMBER-$IMAGE_NUMBER] - -n, --num-images N Number of images to generate. Will always include - start/end frame, unless N = 1, in which case the image - will be the frame at the mid-point in the scene. - -j, --jpeg Set output format to JPEG. [default] - -w, --webp Set output format to WebP. - -q, --quality Q JPEG/WebP encoding quality, from 0-100 (higher - indicates better quality). For WebP, 100 indicates - lossless. [default: JPEG: 95, WebP: 100] - -p, --png Set output format to PNG. - -c, --compression C 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. [default: 3] -``` - - -## `split-video` Command - -```md -PySceneDetect split-video Command ----------------------------------------------------- -Usage: scenedetect split-video [OPTIONS] - - Split input video(s) using ffmpeg or mkvmerge. - -Options: - -o, --output DIR Output directory to save videos to. Overrides - global option -o/--output if set. - -f, --filename NAME File name format, to use when saving image files. - You can use the $VIDEO_NAME and $SCENE_NUMBER - macros in the file name. [default: $VIDEO_NAME- - Scene-$SCENE_NUMBER] - -h, --high-quality Encode video with higher quality, overrides -f - option if present. Equivalent to specifying - --rate-factor 17 and --preset slow. - -a, --override-args ARGS Override codec arguments/options passed to FFmpeg - when splitting and re-encoding scenes. Use double - quotes (") around specified arguments. Must - specify at least audio/video codec to use (e.g. -a - "-c:v [...] and -c:a [...]"). [default: "-c:v - libx264 -preset veryfast -crf 22 -c:a copy"] - -q, --quiet Suppresses output from external video splitting - tool. - -c, --copy Copy instead of re-encode using mkvmerge instead - of ffmpeg for splitting videos. All other - arguments except -o/--output and -q/--quiet are - ignored in this mode, and output files will be - named $VIDEO_NAME-$SCENE_NUMBER.mkv. Significantly - faster when splitting videos, however, output - videos sometimes may not be split exactly, - especially if the scenes are very short in length, - or the input video is heavily compressed. This can - lead to smaller scenes being merged with others, - or scene boundaries being shifted in time - thus - when using this option, the number of videos - written may not match the number of scenes that - was detected. - -crf, --rate-factor RATE Video encoding quality (x264 constant rate - factor), from 0-100, where lower values represent - better quality, with 0 indicating lossless. - [default: 22, if -h/--high-quality is set: 17] - -p, --preset LEVEL Video compression quality preset (x264 preset). - Can be one of: ultrafast, superfast, veryfast, - faster, fast, medium, slow, slower, and veryslow. - Faster modes take less time to run, but the output - files may be larger. [default: veryfast, if - -h/--high quality is set: slow] -``` - diff --git a/docs/reference/creating-new-scene-detectors.md b/docs/reference/creating-new-scene-detectors.md deleted file mode 100644 index 2bfbe92e..00000000 --- a/docs/reference/creating-new-scene-detectors.md +++ /dev/null @@ -1,44 +0,0 @@ - - -The complete PySceneDetect Python API reference [can be found *here* [PySceneDetect Manual]](http://pyscenedetect-manual.readthedocs.io/) - -------------------------------- - - - -Creating a new scene detection method is intuitive if you are familiar with Python and OpenCV already. A `SceneDetector` is an object implementing the following class & methods (only prototypes are shown as an example): - -```python -from scenedetect.scene_detector import 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. - - Prototype method, no actual detection. - """ - return - - def post_process(self, scene_list): - pass -``` - -See the actual `scenedetect/scene_detector.py` source file for specific 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. - -`process_frame(...)` is called for each frame in sequence, passing the following arguments: - -- `frame_num`: the number of the current frame being processed -- `frame_img`: frame returned video file or stream (accessible as NumPy array) -- `frame_metrics`: dictionary for memoizing results of detection algorithm calculations for quicker subsequent analyses (if possible) -- `scene_list`: List containing the frame numbers where all scene cuts/breaks occur in the video. - -`post_process(...)` is called ***after** the final frame has been processed, to allow for any stored scene cuts to be written *if required* (e.g. in the case of the `ThresholdDetector`). - -You may also want to look into the implementation of current detectors to understand how frame metrics are saved/loaded to/from a StatsManager for caching and allowing values to be written to a stats file for users to graph and find trends in to tweak detector options. Also see the section on the `SceneManager` in the [Python API Reference](python-api.md) for details. - diff --git a/docs/reference/detection-methods.md b/docs/reference/detection-methods.md deleted file mode 100644 index a7908e09..00000000 --- a/docs/reference/detection-methods.md +++ /dev/null @@ -1,17 +0,0 @@ - -## Scene Detection Methods/Algorithms - -This page discusses the scene detection methods/algorithms available for use in PySceneDetect, including details describing the operation of the detection method, as well as relevant command-line arguments and recommended values. - - -### Content-Aware Detector - -The content-aware scene detector (`detect-content`) works the way most people think of "cuts" between scenes in a movie - given two frames, do they belong to the same scene, or different scenes? The content-aware scene detector finds areas where the *difference* between two subsequent frames exceeds the threshold value that is set (a good value to start with is `--threshold 30`). - -This allows you to detect cuts between scenes both containing content, rather than how most traditional scene detection methods work. With a properly set threshold, this method can even detect minor, abrupt changes, such as [jump cuts](https://en.wikipedia.org/wiki/Jump_cut) in film. - - -### Threshold Detector - -The threshold-based scene detector (`detect-threshold`) is how most traditional scene detection methods work (e.g. the `ffmpeg blackframe` filter), by comparing the intensity/brightness of the current frame with a set threshold, and triggering a scene cut/break when this value crosses the threshold. In PySceneDetect, this value is computed by averaging the R, G, and B values for every pixel in the frame, yielding a single floating point number representing the average pixel value (from 0.0 to 255.0). - diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md deleted file mode 100644 index f557af20..00000000 --- a/docs/reference/python-api.md +++ /dev/null @@ -1,35 +0,0 @@ - - -API Reference ----------------------------------------------------------- - -The complete PySceneDetect Python API reference can be found in the [PySceneDetect Manual](http://pyscenedetect-manual.readthedocs.io/) which is located at: - -[http://pyscenedetect-manual.readthedocs.io/](http://pyscenedetect-manual.readthedocs.io/) - - -API Overview -========================================================== - -There are two main modules: - - - scenedetect - - scenedetect.detectors - -Classes from main `scenedetect` module: - - - FrameTimecode - used to store timecodes as well as perform arithmetic on timecode values (addition/subtraction/comparison) with frame-accurate precision - - SceneManager - high-level manager to coordinate SceneDetector, VideoManager, and optionally, StatsManager objects - - VideoManager - used to load video(s) and provide seeking - - StatsManager - used to store/cache frame metrics to speed up subsequent scene detection runs on the same video, and optionally, save/load to/from a CSV file - - SceneDetector - base class used to implement detection algorithms (e.g. ContentDetector, ThresholdDetector) - -SceneDetector objects available in the `scenedetect.detectors` module: - - - ThresholdDetector - detects fade-outs/fade-ins to/from black by looking at the intensity/brightness of the video - - ContentDetector - detects scene cuts/content changes by converting the video to the HSV colourspace - - All functions are well documented with complete docstrs, and documentation can be found by calling help() from a Python REPL or browsing the complete PySceneDetect v0.5 API Reference below. Also note that auto-generated documentation (via the `pydoc` command/module) can be generated. - -The complete PySceneDetect Python API reference [can be found *here* (link).](http://breakthrough.github.io/PySceneDetect/) - diff --git a/docs/reference/python-usage.md b/docs/reference/python-usage.md deleted file mode 100644 index c3512aae..00000000 --- a/docs/reference/python-usage.md +++ /dev/null @@ -1,91 +0,0 @@ - -Using PySceneDetect in Python ----------------------------------------------------------- - -PySceneDetect can also be used from within other Python programs, or even the Python REPL itself. PySceneDetect allows you to perform scene detection on a video file, yielding a list of scene cuts/breaks at the exact frame number where the scene boundaries occur. - -The general usage workflow is to determine which detection method and threshold to use (this can even be done iteratively), using these values to create a `SceneDetector` object, the type of which depends on the detection method you want to use (e.g. `ThresholdDetector`, `ContentDetector`). A list of `SceneDetector` objects is then passed with an open `VideoCapture` object and an empty list to the `scenedetect.detect_scenes()` function, which appends the frame numbers of any detected scene boundaries to the list (the function itself returns the number of frames read from the video file). - -Note that the complete PySceneDetect Python API reference [can be found *here* [PySceneDetect Manual].](http://pyscenedetect-manual.readthedocs.io/) - - - -### Example - -The following short program/code sample ([the `api_test.py` file in the `tests` folder]((https://github.com/Breakthrough/PySceneDetect/blob/master/tests/api_test.py))) illustrates the general workflow and usage of the `scenedetect` module to perform scene detection programmatically. It provides a good example as to the general usage of the PySceneDetect Python API for detecting the scenes on an input video and printing the scenes to the terminal/console. - - -```python -from __future__ import print_function -import os - -import scenedetect -from scenedetect.video_manager import VideoManager -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.stats_manager import StatsManager -from scenedetect.detectors import ContentDetector - -STATS_FILE_PATH = 'testvideo.stats.csv' - -def main(): - # Create a video_manager point to video file testvideo.mp4. Note that multiple - # videos can be appended by simply specifying more file paths in the list - # passed to the VideoManager constructor. Note that appending multiple videos - # requires that they all have the same frame size, and optionally, framerate. - video_manager = VideoManager(['testvideo.mp4']) - stats_manager = StatsManager() - scene_manager = SceneManager(stats_manager) - # Add ContentDetector algorithm (constructor takes detector options like threshold). - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - - try: - # If stats file exists, load it. - if os.path.exists(STATS_FILE_PATH): - # Read stats from CSV file opened in read mode: - with open(STATS_FILE_PATH, 'r') as stats_file: - stats_manager.load_from_csv(stats_file, base_timecode) - - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 20.0 # 00:00:20.000 - # Set video_manager duration to read frames from 00:00:00 to 00:00:20. - video_manager.set_duration(start_time=start_time, end_time=end_time) - - # Set downscale factor to improve processing speed. - video_manager.set_downscale_factor() - - # Start video_manager. - video_manager.start() - - # Perform scene detection on video_manager. - scene_manager.detect_scenes(frame_source=video_manager, - start_time=start_time) - - # Obtain list of detected scenes. - scene_list = scene_manager.get_scene_list(base_timecode) - # Like FrameTimecodes, each scene in the scene_list can be sorted if the - # list of scenes becomes unsorted. - - print('List of scenes obtained:') - 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(),)) - - # We only write to the stats file if a save is required: - if stats_manager.is_save_required(): - with open(STATS_FILE_PATH, 'w') as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode) - - finally: - video_manager.release() - -if __name__ == "__main__": - main() -``` - - -The scene list returned by the `SceneManager.get_scene_list(...)` method consists of the start and (one past) the end frame of each scene, in the form of a `FrameTimecode` object. Each `FrameTimecode` can be converted to the appropriate working/output format via the `get_timecode()`, `get_frames()`, or `get_sceonds()` methods as shown above; see the API documentation for `FrameTimecode` objects for details. - diff --git a/manual/_static/pyscenedetect_logo.png b/manual/_static/pyscenedetect_logo.png deleted file mode 100644 index 63471a6a..00000000 Binary files a/manual/_static/pyscenedetect_logo.png and /dev/null differ diff --git a/manual/_static/pyscenedetect_logo_small.png b/manual/_static/pyscenedetect_logo_small.png deleted file mode 100644 index 43365948..00000000 Binary files a/manual/_static/pyscenedetect_logo_small.png and /dev/null differ diff --git a/manual/api.rst b/manual/api.rst deleted file mode 100644 index 4e1bf46a..00000000 --- a/manual/api.rst +++ /dev/null @@ -1,71 +0,0 @@ - -*********************************************************************** -The ``scenedetect`` Module -*********************************************************************** - - -======================================================================= -Overview -======================================================================= - -The ``scenedetect`` module is organized into several sub-modules, each -containing a particular class. Every module has the same name of the -implemented class in `lowercase_underscore` format, whereas the class -name is in `PascalCase` format. There are also some constants, -functions, and exceptions defined in various modules that are -documented in the section describing the associated class. - -The following is an overview of the modules and classes -provided in the ``scenedetect`` package: - - * ``scenedetect``: Main PySceneDetect module. - - * ``scenedetect.frame_timecode``: Contains - :py:class:`FrameTimecode ` - class for storing, converting, and performing arithmetic on timecodes - with frame-accurate precision. - - - * ``scenedetect.video_manager``: Contains - :py:class:`VideoManager ` - class for loading one or more videos, providing seeking, and downscaling. - - * ``scenedetect.scene_manager``: Contains - :py:class:`SceneManager ` - class for applying `SceneDetector` objects on a `VideoManager`, - and optionally using a `StatsManager` as a cache. - - * ``scenedetect.stats_manager``: Contains - :py:class:`StatsManager ` - class for caching frame metrics and loading/saving them to disk in - CSV format for analysis. Also be used as a persistent cache - to make several scene detection runs on the same video source - `significantly` faster. - - * ``scenedetect.scene_detector``: Contains - :py:class:`SceneDetector ` - base class for implementing scene detection algorithms. - - * ``scenedetect.detectors``: Contains all detection algorithm - implementations, which are classes that inherit from - :py:class:`SceneDetectors `. - - * ``scenedetect.detectors.content_detector``: The - :py:class:`ContentDetector ` - algorithm, which detects fast changes/cuts in video content. - * ``scenedetect.detectors.threshold_detector``: The - :py:class:`ThresholdDetector ` - algorithm, which detects changes in video brightness/intensity. - - * ``scenedetect.video_splitter``: Contains - helper functions to use external tools after processing - to split the video into individual scenes. - - -======================================================================= -Example -======================================================================= - -For an example of using the PySceneDetect API to perform scene detection, -take a look at the :ref:`example in the SceneManager reference`. - diff --git a/manual/api/detectors.rst b/manual/api/detectors.rst deleted file mode 100644 index 6d870eaa..00000000 --- a/manual/api/detectors.rst +++ /dev/null @@ -1,24 +0,0 @@ - -Detection Algorithms ----------------------------------------- - -.. automodule:: scenedetect.detectors - :members: - :undoc-members: - - -ContentDetector -========================================= - -.. automodule:: scenedetect.detectors.content_detector - :members: - :undoc-members: - - -ThresholdDetector -========================================= - -.. automodule:: scenedetect.detectors.threshold_detector - :members: - :undoc-members: - diff --git a/manual/api/frame_timecode.rst b/manual/api/frame_timecode.rst deleted file mode 100644 index 8beef8ef..00000000 --- a/manual/api/frame_timecode.rst +++ /dev/null @@ -1,78 +0,0 @@ - -FrameTimecode ----------------------------------------------- - -.. automodule:: scenedetect.frame_timecode - - -Usage Examples -========================================= - -A :py:class:`FrameTimecode` can be created by specifying the frame number as an integer, along -with the framerate: - -.. code:: python - - x = FrameTimecode(timecode = 0, fps = 29.97) - - -It can also be created from a floating-point number of seconds. Note that calling -:py:meth:`x.get_frames() ` will return 200 in this case (10.0 seconds at 20.0 frames/sec): - -.. code:: python - - x = FrameTimecode(timecode = 10.0, fps = 20.0) - - -``timecode`` can also be specified as a string in "HH:MM:SS[.nnn]" format. Note that -calling :py:meth:`x.get_frames() ` will return 600 in this -case (1 minute, or 60 seconds, at 10 frames/sec): - -.. code:: python - - x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0) - - -:py:class:`FrameTimecode` objects can be added and subtracted. Note, however, that a negative -timecode is not representable by a :py:class:`FrameTimecode`, and subtractions towards/past zero -will wrap at zero. - -.. warning:: - - Be careful when subtracting :py:class:`FrameTimecode` objects. - 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 - print(c) - print(d) - -When performing arithmetic/comparison operations with :py:class:`FrameTimecode` objects, -the other operand can be a :py:class:`FrameTimecode`, an `int` number of frames, -a `float` number of seconds, or a `str` of the form `"HH:MM:SS[.nnn]"`. For example: - -.. 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") - # The same goes for comparison. - print((x + 10.0) == "00:01:10.000") - - - -``FrameTimecode`` Class -========================================= - -.. autoclass:: scenedetect.frame_timecode.FrameTimecode - :members: - :undoc-members: - - diff --git a/manual/api/scene_detector.rst b/manual/api/scene_detector.rst deleted file mode 100644 index 620fa996..00000000 --- a/manual/api/scene_detector.rst +++ /dev/null @@ -1,8 +0,0 @@ - -SceneDetector -------------------------------------------------- - -.. automodule:: scenedetect.scene_detector - :members: - :undoc-members: - :private-members: diff --git a/manual/api/scene_manager.rst b/manual/api/scene_manager.rst deleted file mode 100644 index a288ebf1..00000000 --- a/manual/api/scene_manager.rst +++ /dev/null @@ -1,133 +0,0 @@ - -*********************************************************************** -SceneManager -*********************************************************************** - -.. automodule:: scenedetect.scene_manager - - -.. _scenemanager-example: - -======================================================================= -Usage Example -======================================================================= - -In the code example below, we create a function ``find_scenes()`` which performs -the following actions: - - * loads a video file by path (`str`) as argument `video_path` using a - :py:class:`VideoManager ` - * loads/saves a stats file for the video to ``{video_path}.stats.csv`` using a - :py:class:`StatsManager ` - * performs content-aware scene detection on the video using a - :py:class:`ContentDetector ` - bound to a :py:class:`SceneManager` - * ``print()`` out a table of detected scenes to the terminal/console - * returns a list of tuples of - :py:class:`FrameTimecode ` - objects of the start and end times for each detected scene - -This example is a modified version of -`the api_test.py file `_, -and shows complete usage of a -:py:class:`SceneManager ` object -to perform content-aware scene detection using the -:py:class:`ContentDetector `, -printing a list of scenes, and both saving/loading a stats file. - -.. code:: python - - from __future__ import print_function - import os - - # Standard PySceneDetect imports: - from scenedetect.video_manager import VideoManager - from scenedetect.scene_manager import SceneManager - # For caching detection metrics and saving/loading to a stats file - from scenedetect.stats_manager import StatsManager - - # For content-aware scene detection: - from scenedetect.detectors.content_detector import ContentDetector - - - def find_scenes(video_path): - # type: (str) -> List[Tuple[FrameTimecode, FrameTimecode]] - video_manager = VideoManager([video_path]) - stats_manager = StatsManager() - # Construct our SceneManager and pass it our StatsManager. - scene_manager = SceneManager(stats_manager) - - # Add ContentDetector algorithm (each detector's constructor - # takes detector options, e.g. threshold). - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - - # We save our stats file to {VIDEO_PATH}.stats.csv. - stats_file_path = '%s.stats.csv' % video_path - - scene_list = [] - - try: - # If stats file exists, load it. - if os.path.exists(stats_file_path): - # Read stats from CSV file opened in read mode: - with open(stats_file_path, 'r') as stats_file: - stats_manager.load_from_csv(stats_file, base_timecode) - - # Set downscale factor to improve processing speed. - video_manager.set_downscale_factor() - - # Start video_manager. - video_manager.start() - - # Perform scene detection on video_manager. - scene_manager.detect_scenes(frame_source=video_manager) - - # Obtain list of detected scenes. - scene_list = scene_manager.get_scene_list(base_timecode) - # Each scene is a tuple of (start, end) FrameTimecodes. - - print('List of scenes obtained:') - 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(),)) - - # We only write to the stats file if a save is required: - if stats_manager.is_save_required(): - with open(stats_file_path, 'w') as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode) - - finally: - video_manager.release() - - return scene_list - -The use of a :py:class:`StatsManager ` allows -subsequent calls to ``find_scenes()`` (specifically the -:py:meth:`detect_scenes ` method) with the same video -to be significantly faster, and saving/loading the stats file to a CSV file on disk -allows the stats to persist even after the program exits. This is the same file -that is generated when running the ``scenedetect`` command with the ``-s``/``--stats`` -option. - - -======================================================================= -``SceneManager`` Class -======================================================================= - -.. autoclass:: scenedetect.scene_manager.SceneManager - :members: - :undoc-members: - - -======================================================================= -``scene_manager`` Functions -======================================================================= - -.. autofunction:: scenedetect.scene_manager.get_scenes_from_cuts - -.. autofunction:: scenedetect.scene_manager.write_scene_list - diff --git a/manual/api/stats_manager.rst b/manual/api/stats_manager.rst deleted file mode 100644 index 87e9276f..00000000 --- a/manual/api/stats_manager.rst +++ /dev/null @@ -1,33 +0,0 @@ - ------------------------------------------------------------------------ -StatsManager ------------------------------------------------------------------------ - -.. automodule:: scenedetect.stats_manager - - -======================================================================= -``StatsManager`` Class -======================================================================= - -.. autoclass:: scenedetect.stats_manager.StatsManager - :members: - :undoc-members: - - -======================================================================= -Exceptions -======================================================================= - -.. autoexception:: scenedetect.stats_manager.FrameMetricRegistered - -.. autoexception:: scenedetect.stats_manager.FrameMetricNotRegistered - -.. autoexception:: scenedetect.stats_manager.StatsFileCorrupt - -.. autoexception:: scenedetect.stats_manager.StatsFileFramerateMismatch - -.. autoexception:: scenedetect.stats_manager.NoMetricsRegistered - -.. autoexception:: scenedetect.stats_manager.NoMetricsSet - diff --git a/manual/api/video_manager.rst b/manual/api/video_manager.rst deleted file mode 100644 index d3376704..00000000 --- a/manual/api/video_manager.rst +++ /dev/null @@ -1,162 +0,0 @@ - -VideoManager ---------------------------------------------------------------- - -.. automodule:: scenedetect.video_manager - - -Usage Example -=============================================================== - -Assuming we have a file `video.mp4`, we can load it and iterate through the -first 2 minutes using the default downscale factor as follows. - -We start by creating a :py:class:`VideoManager` and getting the base FrameTimecode: - -.. code:: python - - video_manager = VideoManager(['video.mp4']) - base_timecode = video_manager.get_base_timecode() - -Note that the first argument to the :py:class:`VideoManager` constructor is a *list* of -video files to open. Any number of videos can be *appended* by adding more -paths to the list, however, each video must have the same framerate and -resolution. - -.. tip:: - - If the video framerates differ slightly, supply the ``framerate`` - argument to override the framerate check: - - .. code:: python - - video_manager = VideoManager(['video1.mp4', 'video2.mp4'], - framerate=23.976) - # base_timecode will have a framerate of 23.976 now. - base_timecode = video_manager.get_base_timecode() - - -Next, we set the duration to 2 minutes and the downscale factor to the default -based on video resolution: - -.. code:: python - - video_manager.set_duration(duration=base_timecode + '00:02:00') - video_manager.set_downscale_factor() - -:py:meth:`set_duration() ` takes up to two arguments of -``start_time``, ``end_time``, and ``duration``, where ``end_time`` and ``duration`` -are mutually exclusive. Each argument should be a -:py:class:`FrameTimecode ` object. - -Note that if you are using a :py:class:`SceneManager ` -and set the ``start_time`` argument of :py:meth:`VideoManager.set_duration`, -you must pass set the same ``start_time`` argument to the -:py:meth:`SceneManager.detect_scenes() ` -method. - -After calling the above, the number of frames returned by the :py:class:`VideoManager` -will be limited to 2 minutes of video exactly, and setting the default downscale factor -ensures an adequate frame size for performing scene detection in most use cases. - -.. warning:: - - The :py:meth:`~VideoManager.set_duration` and :py:meth:`~VideoManager.set_downscale_factor` - methods must be called **before** :py:meth:`~VideoManager.start`. - -Now that all of our options have been set, we can call :py:meth:`VideoManager.start` -and begin processing frames the same way we would with an OpenCV VideoCapture object: - -.. code:: python - - video_manager.start() - while True: - ret_val, frame_image = video_manager.read() - if not ret_val: - break - # Do stuff with frame_image here. - - -Note that the :py:meth:`VideoManager.read`, :py:meth:`VideoManager.grab` and -:py:meth:`VideoManager.retrieve` methods all have the same prototypes and function -as their OpenCV counterparts. Likewise, the frame image returned by these -methods is a standard Numpy ``ndarray`` which can be operated on as expected. - -Lastly, when all processing is done, make sure to call :py:meth:`VideoManager.release` -to cleanup all resources acquired by the :py:class:`VideoManager` object. - -.. hint:: - Use a ``try``/``finally`` block to ensure that the :py:meth:`~VideoManager.release` - method is called. For example: - - .. code:: python - - video_manager = VideoManager(['video.mp4']) - try: - video_manager.set_downscale_factor() - video_manager.start() - while True: - if not video_manager.grab(): - break - finally: - # Ensures release() is called even if an exception - # is thrown during any code added to process frames. - video_manager.release() - - - -When passing a :py:class:`VideoManager` to a -:py:class:`SceneManager ` class, the -:py:meth:`~VideoManager.start` method must already have been called. See the -:ref:`example in the SceneManager reference` for more details. - - -``VideoManager`` Class -=============================================================== - -.. autoclass:: scenedetect.video_manager.VideoManager - :members: - :undoc-members: - -``video_manager`` Functions and Constants -=============================================================== - -The following functions and constants are available in the ``scenedetect.video_manager`` module. - -.. autodata:: scenedetect.video_manager.DEFAULT_DOWNSCALE_FACTORS - -.. autofunction:: scenedetect.video_manager.compute_downscale_factor - -.. autoexception:: scenedetect.video_manager.InvalidDownscaleFactor - -.. autofunction:: scenedetect.video_manager.get_video_name - -.. autofunction:: scenedetect.video_manager.get_num_frames - -.. autofunction:: scenedetect.video_manager.open_captures - -.. autofunction:: scenedetect.video_manager.release_captures - -.. autofunction:: scenedetect.video_manager.close_captures - -.. autofunction:: scenedetect.video_manager.validate_capture_framerate - -.. autofunction:: scenedetect.video_manager.validate_capture_parameters - - -Exceptions -=============================================================== - -.. autoexception:: scenedetect.video_manager.VideoOpenFailure - -.. autoexception:: scenedetect.video_manager.VideoFramerateUnavailable - -.. autoexception:: scenedetect.video_manager.VideoParameterMismatch - -.. autoexception:: scenedetect.video_manager.VideoDecodingInProgress - -.. autoexception:: scenedetect.video_manager.VideoDecoderNotStarted - -.. autoexception:: scenedetect.video_manager.InvalidDownscaleFactor - - diff --git a/manual/api/video_splitter.rst b/manual/api/video_splitter.rst deleted file mode 100644 index 7b003463..00000000 --- a/manual/api/video_splitter.rst +++ /dev/null @@ -1,7 +0,0 @@ - -Video Splitting ------------------------------------ - -.. automodule:: scenedetect.video_splitter - :members: - :undoc-members: diff --git a/manual/cli/commands.rst b/manual/cli/commands.rst deleted file mode 100644 index e9afe510..00000000 --- a/manual/cli/commands.rst +++ /dev/null @@ -1,285 +0,0 @@ - -*********************************************************************** -Command Reference -*********************************************************************** - -The following commands are available when using ``scenedetect``. -Several commands can be combined together (the order does not -matter) to control various input/output options. - -The following is a list of the available commands along with a -brief description of the command's function and an example. - - -Help/information commands (prints information and quits): - - - ``help`` - Prints help and usage information for commands - ``help``, ``help [command]``, or ``help all`` - - ``about`` - Prints license and copyright information about PySceneDetect - ``about`` - - ``version`` - Print PySceneDetect version number - ``version`` - - -Input/output commands (applies to input videos and detected scenes): - - - ``time`` - Set start time/end time/duration of input video(s) - ``time --start 00:01:00 --end 00:02:00`` - - ``list-scenes`` - Write list of scenes and timecodes to the terminal as well as a .CSV file - ``list-scenes`` - - ``save-images`` - Saves a given number of frames from every detected scene as images, by default JPEG - ``save-images --quality 80`` - - ``split-video`` - Automatically split input video using either `ffmpeg` (`split-video` or `split-video -hq` for higher quality), or `mkvmerge` (`split-video --copy`) - ``split-video`` or ``split-video -hq`` for higher quality, ``split-video --copy`` for no re-encoding - - -.. note:: When using multiple commands, make sure to not - specify the same command twice. The order of commands does - not matter, but each command should only be specified once. - - -======================================================================= -``help``, ``version``, and ``about`` -======================================================================= - -**The** ``help`` **command** prints PySceneDetect options and help information. Usage: - - * ``help`` - Shows the main `scenedetect` program options and a list of commands. - * ``help [command]`` - Shows options for a specific command/detector (`help list-scenes`, `help detect-threshold`). - * ``help all`` - Shows the options and help information for *all* commands. - -**The** ``version`` **command** command prints the version of PySceneDetect that is installed. - -**The** ``about`` **command** prints PySceneDetect copyright, licensing, and redistribution -information. This includes a list of all third-party software components that -PySceneDetect uses or interacts with, as well as a reference to the license and -copyright information for each component. - - -Usage Examples ------------------------------------------------------------------------ - -The ``help`` command: - - ``scenedetect help`` - - ``scenedetect help all`` - - ``scenedetect help detect-content`` - -The ``about`` command: - - ``scenedetect about`` - -The ``version`` command: - - ``scenedetect version`` - -The program will terminate immediately after printing the requested information -if any of the above commands are given. - - -======================================================================= -``time`` -======================================================================= - -**The** ``time`` **command** is used for seeking the input video source, allowing you -to set the start time, end time, and duration. - - -Timecode Formats ------------------------------------------------------------------------ - -Timecodes can be specified in the following formats: - - * Timestamp of hours/minutes/seconds in format ``HH:MM:SS`` or ``HH:MM:SS.nnn`` - (`00:01:40` indicates 1 minute and 40 seconds). The `HH`, `MM`, and `SS` fields - are all required; `.nnn` is optional. - * Exact number of frames ``NNNN`` (`100` indicates frame 100) - * Time in seconds ``SSSS.SSSs`` followed by lowercase `s` (`100s` indicates 100 seconds) - - -Command Options ------------------------------------------------------------------------ - -The `time` command takes the following options: - - * ``-s``, ``--start TIMECODE`` - Time in video to begin detecting scenes. `TIMECODE` format - is the same as other arguments. [default: 0] - * ``-d``, ``--duration TIMECODE`` - Maximum time in video to process. `TIMECODE` format - is the same as other arguments. Mutually exclusive - with `--end` / `-e`. - * ``-e``, ``--end TIMECODE`` - Time in video to end detecting scenes. `TIMECODE` - format is the same as other arguments. Mutually - exclusive with `--duration` / `-d`. - - -Usage Examples ------------------------------------------------------------------------ - -Using the `detect-content` detector, we start at 1 minute in and parse 30.5 seconds of `video.mp4`: - - ``scenedetect --input video.mp4 time --start 00:01:00 --duration 30.5s detect-content`` - -Same as above, but setting the end time instead of duration: - - ``scenedetect --input video.mp4 time --start 00:01:00 --end 00:01:30.500 detect-content`` - -Process the first 1000 frames only: - - ``scenedetect --input video.mp4 time --duration 1000 detect-content`` - - -======================================================================= -``list-scenes`` -======================================================================= - -**The** ``list-scenes`` **command** is used to print out and write to a CSV file -a table of all scenes, their start/end timecodes, and frame numbers. The file also -includes the cut list, which is a list of timecodes of each scene boundary. - - - -Command Options ------------------------------------------------------------------------ - -The `list-scenes` command takes the following options: - - * ``-o``, ``--output DIR`` - Output directory to save videos to. Overrides global - option `-o`/`--output` if set. - * ``-f, ``--filename NAME`` - Filename format to use for the scene list CSV file. - You can use the `$VIDEO_NAME` macro in the file name. - [default: `$VIDEO_NAME-Scenes.csv`] - * ``-n, ``--no-output-file`` - Disable writing scene list CSV file to disk. If set, - `-o`/`--output` and `-f`/`--filename` are ignored. - * ``-q``, ``--quiet`` - Suppresses output of the table printed by the `list-scenes` - command. - - -Usage Examples ------------------------------------------------------------------------ - -Print table of detected scenes for `video.mp4` and save to CSV file `video-Scenes.csv`: - - ``scenedetect --input video.mp4 detect-content list-scenes`` - -Same as above, but *don't* create output file: - - ``scenedetect --input video.mp4 detect-content list-scenes -n`` - - -======================================================================= -``save-images`` -======================================================================= - -**The** ``save-images`` **command** creates images for each detected scene. -It saves a set number of images for each detected scene, always including -the first and last frames. - -Command Options ------------------------------------------------------------------------ - -The `save-images` command takes the following options: - - * ``-o``, ``--output DIR`` - Output directory to save images to. Overrides global - option -o/--output if set. - * ``-f``, ``--filename NAME`` - Filename format, *without* extension, to use when - saving image files. You can use the $VIDEO_NAME, - $SCENE_NUMBER, and $IMAGE_NUMBER macros in the file - name. [default: $VIDEO_NAME- - Scene-$SCENE_NUMBER-$IMAGE_NUMBER] - * ``-n``, ``--num-images N`` - Number of images to generate. Will always include - start/end frame, unless N = 1, in which case the image - will be the frame at the mid-point in the scene. - * ``-j``, ``--jpeg`` - Set output format to JPEG. [default] - * ``-w``, ``--webp`` - Set output format to WebP. - * ``-q``, ``--quality Q`` - JPEG/WebP encoding quality, from 0-100 (higher - indicates better quality). For WebP, 100 indicates - lossless. [default: JPEG: 95, WebP: 100] - * ``-p``, ``--png`` - Set output format to PNG. - * ``-c``, ``--compression C`` - 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. [default: 3] - - -======================================================================= -``split-video`` -======================================================================= - -**The** ``split-video`` **command** splits the input video into individual clips, -by creating a new video clip for each detected scene. - -Command Options ------------------------------------------------------------------------ - -The `split-video` command takes the following options: - - * ``-o``, ``--output DIR`` - Output directory to save videos to. Overrides - global option `-o`/`--output` if set. - * ``-f``, ``--filename NAME`` - File name format, *without* extension, to use when saving image files. - You can use the `$VIDEO_NAME` and `$SCENE_NUMBER` - macros in the file name. [default: `$VIDEO_NAME- - Scene-$SCENE_NUMBER`] - * ``-h``, ``--high-quality`` - Encode video with higher quality, overrides `-a` - option if present. Equivalent to specifying - --rate-factor 17 and --preset slow. - * ``-a``, ``--override-args ARGS`` - Override codec arguments/options passed to FFmpeg - when splitting and re-encoding scenes. Use double - quotes (") around specified arguments. Must - specify at least audio/video codec to use (e.g. `-a - "-c:v [...] and -c:a [...]"`). [default: `"-c:v - libx264 -preset veryfast -crf 22 -c:a copy"`] - * ``-q``, ``--quiet`` - Suppresses output from external video splitting - tool. - * ``-c``, ``--copy`` - Copy instead of re-encode using mkvmerge instead - of ffmpeg for splitting videos. All other - arguments except -o/--output and -q/--quiet are - ignored in this mode, and output files will be - named $VIDEO_NAME-$SCENE_NUMBER.mkv. Significantly - faster when splitting videos, however, output - videos sometimes may not be split exactly, - especially if the scenes are very short in length, - or the input video is heavily compressed. This can - lead to smaller scenes being merged with others, - or scene boundaries being shifted in time - thus - when using this option, the number of videos - written may not match the number of scenes that - was detected. - * ``-crf``, ``--rate-factor RATE`` - Video encoding quality (x264 constant rate - factor), from 0-100, where lower values represent - better quality, with 0 indicating lossless. - [default: 22, if `-hq`/`--high-quality` is set: 17] - * ``-p``, ``--preset LEVEL`` - Video compression quality preset (x264 preset). - Can be one of: ultrafast, superfast, veryfast, - faster, fast, medium, slow, slower, and veryslow. - Faster modes take less time to run, but the output - files may be larger. [default: veryfast, if - `-hq`/`--high-quality` is set: slow] - diff --git a/manual/cli/detectors.rst b/manual/cli/detectors.rst deleted file mode 100644 index dec0fefc..00000000 --- a/manual/cli/detectors.rst +++ /dev/null @@ -1,90 +0,0 @@ - -*********************************************************************** -Scene Detection Commands -*********************************************************************** - -There are currently two implemented scene detection algorithms, threshold -based detection (``detect-threshold``), and content-aware detection -(``detect-content``). Each detector can be selected by adding the -respective `detect-` command, and any relevant options, after setting -the main ``scenedetect`` command global options. In general, commands -should follow the form: - - ``scenedetect [global options] [detector] [commands]`` - -For example, to use the `detect-content` detector on a file `video.mp4`, -writing a stats file to file `video.stats.csv`, and printing a list of -detected scenes to the terminal: - - ``scenedetect -i video.mp4 -s video.stats.csv detect-content list-scenes -n`` - -Several more command line interface examples are shown in the following section. - -======================================================================= -``detect-content`` -======================================================================= - -Perform content detection algorithm on input video(s). - - -Detector Options ------------------------------------------------------------------------ - -The ``detect-content`` detector takes the following options: - - -t, --threshold VAL Threshold value (float) that the delta_hsv frame - metric must exceed to trigger a new scene. - Refers to frame metric delta_hsv_avg in stats - file. [default: 30.0] - -m, --min-scene-len FRAMES Minimum size/length of any scene, in number of - frames. [default: 15] - - - -Usage Examples ------------------------------------------------------------------------ - - ``detect-content`` - - ``detect-content --threshold 27.5`` - - -======================================================================= -``detect-threshold`` -======================================================================= - - Perform threshold detection algorithm on input video(s). - -Detector Options ------------------------------------------------------------------------ - -The ``detect-threshold`` detector takes the following options: - - -t, --threshold VAL Threshold value (integer) that the delta_rgb - frame metric must exceed to trigger a new scene. - Refers to frame metric delta_rgb in stats file. - [default: 12] - -m, --min-scene-len FRAMES Minimum size/length of any scene, in number of - frames. [default: 15] - -f, --fade-bias PERCENT Percent (%) from -100 to 100 of timecode skew - for where cuts should be placed. -100 indicates - the start frame, +100 indicates the end frame, - and 0 is the middle of both. [default: 0] - -l, --add-last-scene If set, if the video ends on a fade-out, an - additional scene will be generated for the last - fade out position. - -p, --min-percent PERCENT Percent (%) from 0 to 100 of amount of pixels - that must meet the threshold value in orderto - trigger a scene change. [default: 95] - -b, --block-size N Number of rows in image to sum per iteration - (can be tuned for performance in some cases). - [default: 8] - - -Usage Examples ------------------------------------------------------------------------ - - ``detect-threshold`` - - ``detect-threshold --threshold 15`` - diff --git a/manual/cli/global_options.rst b/manual/cli/global_options.rst deleted file mode 100644 index 8b3a0753..00000000 --- a/manual/cli/global_options.rst +++ /dev/null @@ -1,100 +0,0 @@ - -*********************************************************************** - ``scenedetect`` Command -*********************************************************************** - -The options in this section represent the "global" arguments for the -main ``scenedetect`` command. The most commonly used options are the -input video(s) -(`--input video.mp4`), the output directory (`--output video_out`), and -the stats file to use (`--stats video.stats.csv`). - -Your commands should follow the form: - - ``scenedetect [global options] [detector] [commands]`` - -Where `[global options]` are the options on **this** page, `[detector]` is a scene -detection algorithm (`detect-content` or `detect-threshold`), and `[commands]` -are any other commands to be performed, and their own options (e.g. -`time --start 00:01:30`, `split-video -hq`). - -.. note:: - Any options on this page (global options) *must* be set before using - any commands. Your commands should follow the form (where square brackets - denote things that may be optional): - - ``scenedetect (global options) (command-A [command-A options]) (...)`` - - This is because once a command is specified, all options/arguments afterwards - will be parsed assuming they belong to *that* command. - - -======================================================================= -Command Options -======================================================================= - -The ``scenedetect`` command takes the following global options: - - - -i, --input VIDEO [Required] Input video file. May be specified - multiple times to concatenate several videos - together. - -o, --output DIR Output directory for all files (stats file, output - videos, images, log files, etc...). - -f, --framerate FPS Force framerate, in frames/sec (e.g. -f 29.97). - Disables check to ensure that all input videos have - the same framerates. - -d, --downscale N Integer factor to downscale frames by (e.g. 2, 3, - 4...), where the frame is scaled to width/`N` x - height/`N` (thus `-d 1` implies no downscaling). Each - increment speeds up processing by a factor of 4 (e.g. - `-d 2` is 4 times quicker than `-d 1`). Higher values can - be used for high definition content with minimal - effect on accuracy. [default: 2 for SD, 4 for 720p, 6 - for 1080p, 12 for 4k] - -s, --stats CSV Path to stats file (.csv) for writing frame metrics - to. If the file exists, any metrics will be - processed, otherwise a new file will be created. Can - be used to determine optimal values for various scene - detector options, and to cache frame calculations in - order to speed up multiple detection runs. - -l, --logfile LOG Path to log file for writing application logging - information, mainly for debugging. Make sure to set - `-v debug` as well if you are submitting a bug - report. - -v, --verbosity LEVEL Level of debug/info/error information to show. - Can be one of: `none`, `debug`, `info`, `warning`, `error`. - May be overriden by `-q`/`--quiet`. - Setting to `none` will suppress all output except that - generated by actions (e.g. timecode list output). - [default: `info`] - -q, --quiet Suppresses all output of PySceneDetect except for - those from the specified commands. Equivalent to - setting `--verbosity none`. Overrides the current - verbosity level, even if `-v`/`--verbosity` is set. - -fs, --frame-skip N **Not recommended, disallows use of a stats file** - (the `-s`/`--stats` option). - Skips `N` frames during processing (-fs 1 skips every - other frame, processing 50% of the video, -fs 2 - processes 33% of the frames, -fs 3 processes 25%, - etc...). Reduces processing speed at expense of - accuracy. [default: 0] - - -======================================================================= -Example Usage -======================================================================= - -Note again that calls to the `scenedetect` command should be specified as follows: - - ``scenedetect [global options] [detector] [commands]`` - -For example, to use the `--input` and `--stats` options from above along with -the `detect-content` detector on a file `video.mp4`, and using the `list-scenes` -command to print a table of detected scenes to the terminal: - - ``scenedetect -i video.mp4 -s video.stats.csv detect-content list-scenes -n`` - -More examples can be found in the following sections, which detail the options for -each scene detector and all commands. - diff --git a/manual/index.rst b/manual/index.rst deleted file mode 100644 index a7deeaac..00000000 --- a/manual/index.rst +++ /dev/null @@ -1,72 +0,0 @@ - -.. PySceneDetect documentation index file (contains toctree directive). - Copyright (C) 2018 Brandon Castellano. All rights reserved. - - -####################################################################### -PySceneDetect v0.5 Manual -####################################################################### - -This manual refers to both the PySceneDetect -command-line interface (the `scenedetect` command) and the PySceneDetect Python API -(the `scenedetect` module). - -Information regarding installing/downloading PySceneDetect or obtaining the latest -release can be found at -`scenedetect.com `_. Both Python 2.7 and 3.x are supported, however it is suggested to use or migrate -to Python 3.x whenever possible, as it provides better overall performance. - -PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. - -.. note:: - - If you see any errors in this manual, or have any recommendations, - feel free to raise an issue on - `the PySceneDetect issue tracker `_. - -The latest source code for PySceneDetect can be found on Github at -`github.com/Breakthrough/PySceneDetect `_. - -*********************************************************************** -Table of Contents -*********************************************************************** - -======================================================================= -``scenedetect`` Command Reference -======================================================================= - -.. toctree:: - :maxdepth: 2 - :caption: Command-Line Interface [CLI]: - :name: clitoc - - cli/global_options - cli/commands - cli/detectors - -======================================================================= -``scenedetect`` Python Module -======================================================================= - -.. toctree:: - :maxdepth: 3 - :caption: Python API Documentation: - :name: apitoc - - api - api/frame_timecode - api/video_manager - api/scene_manager - api/stats_manager - api/scene_detector - api/detectors - api/video_splitter - -Indices and Tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - - diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 78f279d8..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,37 +0,0 @@ -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" - -repo_url: https://github.com/Breakthrough -repo_name: "PySceneDetect on Github" -copyright: 'Copyright © 2012-2018 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' - -#theme: mkdocs -theme: readthedocs -google_analytics: ['UA-72551323-1', 'auto'] - -pages: -- 'PySceneDetect': - - 'Introduction': 'index.md' - - 'Features & Roadmap': 'features.md' - - 'Download': 'download.md' - - 'Changelog': 'changelog.md' - - 'Bug Reports and Contributing': 'contributing.md' - - 'License & Copyright Information': 'copyright.md' -- 'Getting Started:': - - 'Basic Usage': 'examples/usage.md' - - 'Examples': 'examples/usage-example.md' - - 'Python Interface': 'examples/usage-python.md' - - 'Video Splitting Support': 'examples/video-splitting.md' -- 'Documentation & Reference:': - - 'Command-Line Parameters': 'reference/command-line-params.md' - - 'Scene Detection Method Details': 'reference/detection-methods.md' - - 'Python Interface & Integration With Other Applications': 'reference/python-usage.md' - - 'Python API Reference': 'reference/python-api.md' - - 'Creating New Scene Detection Methods': 'reference/creating-new-scene-detectors.md' -- 'Other Links': - - 'Third-Party Tools & Utilities': 'other/thirdparty.md' - - 'Other Similar Programs': 'other/similar.md' - - 'Useful Resources & Reading Material': 'other/resources.md' - -markdown_extensions: [fenced_code] diff --git a/package-info.rst b/package-info.rst deleted file mode 100644 index 38019f9d..00000000 --- a/package-info.rst +++ /dev/null @@ -1,47 +0,0 @@ - -PySceneDetect -========================================================== - -Video Scene Cut Detection and Analysis Tool ----------------------------------------------------------- - -.. image:: https://readthedocs.org/projects/pyscenedetect/badge/?version=latest - :target: http://pyscenedetect.readthedocs.org/en/latest/?badge=latest - -.. image:: https://img.shields.io/github/release/Breakthrough/PySceneDetect.svg - :target: https://github.com/Breakthrough/PySceneDetect - -.. image:: https://img.shields.io/pypi/status/scenedetect.svg - :target: https://github.com/Breakthrough/PySceneDetect - -.. image:: https://img.shields.io/pypi/l/scenedetect.svg - :target: http://pyscenedetect.readthedocs.org/en/latest/copyright/ - -.. image:: https://img.shields.io/github/stars/Breakthrough/PySceneDetect.svg?style=social&label=View%20on%20Github - :target: https://github.com/Breakthrough/PySceneDetect - ----------------------------------------------------------- - -Website: http://py.scenedetect.com/ - -Documentation: http://manual.scenedetect.com/ - -Github Repo: https://github.com/Breakthrough/PySceneDetect/ - ----------------------------------------------------------- - -PySceneDetect is a command-line tool, written in Python and using OpenCV, which analyzes a video, looking for scene changes or cuts. The output timecodes can then be used with another tool (e.g. `mkvmerge`, `ffmpeg`) to split the video into individual clips (or using the `split-video` command). A frame-by-frame analysis can also be generated for a video, to help with determining optimal threshold values or detecting patterns/other analysis methods for a particular video. - -There are two main detection methods PySceneDetect uses: `detect-threshold` (comparing each frame to a set black level, useful for detecting cuts and fades to/from black), and `detect-content` (compares each frame sequentially looking for changes in content, useful for detecting fast cuts between video scenes, although slower to process). Each mode has slightly different parameters, and is described in detail in the documentation. - -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-content` mode. Once you know what detection mode to use, you can try the parameters recommended below, or generate a statistics file (using the `-s` / `--stats` argument) in order to determine the correct paramters - specifically, the proper threshold value. - -For help or other issues, feel free to submit any bugs or feature requests to Github: https://github.com/Breakthrough/PySceneDetect/issues - ----------------------------------------------------------- - -Licensed under BSD 3-Clause (see the `LICENSE` file for details). - -Copyright (C) 2012-2018 Brandon Castellano. -All rights reserved. - 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/packaging/package-info.rst b/packaging/package-info.rst new file mode 100644 index 00000000..1721e516 --- /dev/null +++ b/packaging/package-info.rst @@ -0,0 +1,50 @@ + +PySceneDetect +========================================================== + +Video Scene Cut Detection and Analysis Tool +---------------------------------------------------------- + +.. image:: https://img.shields.io/pypi/status/scenedetect.svg + :target: https://github.com/Breakthrough/PySceneDetect + +.. image:: https://img.shields.io/github/release/Breakthrough/PySceneDetect.svg + :target: https://github.com/Breakthrough/PySceneDetect + +.. image:: https://img.shields.io/pypi/l/scenedetect.svg + :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 + +---------------------------------------------------------- + +Documentation: https://www.scenedetect.com/docs + +Github Repo: https://github.com/Breakthrough/PySceneDetect/ + +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. + +---------------------------------------------------------- + +**PySceneDetect** is a tool for detecting shot changes in videos, and can automatically split videos into separate clips. PySceneDetect is free and open-source software, and has several detection methods to find fast-cuts and threshold-based fades. + +For example, to split a video: ``scenedetect -i video.mp4 split-video`` + +You can also use the Python API (`docs `_) to do the same: + +.. code-block:: python + + from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg + scene_list = detect('my_video.mp4', AdaptiveDetector()) + split_video_ffmpeg('my_video.mp4', scene_list) + +---------------------------------------------------------- + +Licensed under BSD 3-Clause (see the ``LICENSE`` file for details). + +Copyright (C) 2014 Brandon Castellano. +All rights reserved. + diff --git a/packaging/variants/pyproject-scenedetect-headless.toml b/packaging/variants/pyproject-scenedetect-headless.toml new file mode 100644 index 00000000..67733bc0 --- /dev/null +++ b/packaging/variants/pyproject-scenedetect-headless.toml @@ -0,0 +1,82 @@ +# +# 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-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/packaging/windows/LICENSE-PYTHON b/packaging/windows/LICENSE-PYTHON new file mode 100644 index 00000000..07c93eeb --- /dev/null +++ b/packaging/windows/LICENSE-PYTHON @@ -0,0 +1,603 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + + +Additional Conditions for this Windows binary build +--------------------------------------------------- + +This program is linked with and uses Microsoft Distributable Code, +copyrighted by Microsoft Corporation. The Microsoft Distributable Code +is embedded in each .exe, .dll and .pyd file as a result of running +the code through a linker. + +If you further distribute programs that include the Microsoft +Distributable Code, you must comply with the restrictions on +distribution specified by Microsoft. In particular, you must require +distributors and external end users to agree to terms that protect the +Microsoft Distributable Code at least as much as Microsoft's own +requirements for the Distributable Code. See Microsoft's documentation +(included in its developer tools and on its website at microsoft.com) +for specific details. + +Redistribution of the Windows binary build of the Python interpreter +complies with this agreement, provided that you do not: + +- alter any copyright, trademark or patent notice in Microsoft's +Distributable Code; + +- use Microsoft's trademarks in your programs' names or in a way that +suggests your programs come from or are endorsed by Microsoft; + +- distribute Microsoft's Distributable Code to run on a platform other +than Microsoft operating systems, run-time technologies or application +platforms; or + +- include Microsoft Distributable Code in malicious, deceptive or +unlawful programs. + +These restrictions apply only to the Microsoft Distributable Code as +defined above, not to Python itself or any programs running on the +Python interpreter. The redistribution of the Python interpreter and +libraries is governed by the Python Software License included with this +file, or by other licenses as marked. + + + +-------------------------------------------------------------------------- + +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2010 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@bzip.org +bzip2/libbzip2 version 1.0.6 of 6 September 2010 + +-------------------------------------------------------------------------- + + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a double license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ + + +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState +Corporation and other parties. The following terms apply to all files +associated with the software unless explicitly disclaimed in +individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState +Corporation, Apple Inc. and other parties. The following terms apply to +all files associated with the software unless explicitly disclaimed in +individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (b) (3) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +Copyright (c) 1993-1999 Ioi Kim Lam. +Copyright (c) 2000-2001 Tix Project Group. +Copyright (c) 2004 ActiveState + +This software is copyrighted by the above entities +and other parties. The following terms apply to all files associated +with the software unless explicitly disclaimed in individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +---------------------------------------------------------------------- + +Parts of this software are based on the Tcl/Tk software copyrighted by +the Regents of the University of California, Sun Microsystems, Inc., +and other parties. The original license terms of the Tcl/Tk software +distribution is included in the file docs/license.tcltk. + +Parts of this software are based on the HTML Library software +copyrighted by Sun Microsystems, Inc. The original license terms of +the HTML Library software distribution is included in the file +docs/license.html_lib. + diff --git a/packaging/windows/README.txt b/packaging/windows/README.txt new file mode 100644 index 00000000..2ac1a534 --- /dev/null +++ b/packaging/windows/README.txt @@ -0,0 +1,2 @@ +Run `scenedetect --help` for usage examples. For documentation, see `docs/index.html` or visit +https://www.scenedetect.com/docs. 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/packaging/windows/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 new file mode 100644 index 00000000..0a890840 Binary files /dev/null and b/packaging/windows/installer/Prerequisites/Visual C++ Redistributable for Visual Studio 2015-2019/VC_redist.x64.exe differ 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/packaging/windows/thirdparty.7z b/packaging/windows/thirdparty.7z new file mode 100644 index 00000000..ea4b042a Binary files /dev/null and b/packaging/windows/thirdparty.7z differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..88f2f034 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,173 @@ +# +# 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 . +# + +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[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 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # flake8-bugbear + "B", + # pycodestyle + "E", + "W", + # Pyflakes + "F", + # isort + "I", + # pyupgrade + "UP", + # flake8-simplify + "SIM", + # ruff-native checks + "RUF", +] +ignore = [ + # 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", +] +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 759baf0c..00000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# -# PySceneDetect Python Requirements -# -opencv-python -numpy -click -tqdm -pytest - diff --git a/scenedetect.cfg b/scenedetect.cfg new file mode 100644 index 00000000..d987435e --- /dev/null +++ b/scenedetect.cfg @@ -0,0 +1,387 @@ + +# +# This file contains every possible PySceneDetect config option. +# +# A config file path can be specified via the -c/--config option, or by +# creating a `scenedetect.cfg` file the following location: +# +# Windows: C:/Users/%USERNAME%/AppData/Local/PySceneDetect/scenedetect.cfg +# +# Linux: ~/.config/PySceneDetect/scenedetect.cfg +# $XDG_CONFIG_HOME/scenedetect.cfg +# +# Mac: ~/Library/Preferences/PySceneDetect/scenedetect.cfg +# +# Run `scenedetect --help` to see the exact path on your system which will be +# used (it will be listed under the help text for the -c/--config option). +# + + +# +# GLOBAL OPTIONS +# + +[global] + +# Default detector to use. +# Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist +#default-detector = detect-adaptive + +# Output directory for written files. Defaults to working directory. +#output = /usr/tmp/scenedetect/ + +# Verbosity of console output (debug, info, warning, error, or none). +# Set to none for the same behavior as specifying -q/--quiet. +#verbosity = debug + +# 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 + +# Merge last scene if it is shorter than min-scene-len (yes/no). This can occur +# when a cut is detected just before the video ends. +#merge-last-scene = no + +# Drop scenes shorter than min-scene-len instead of merging (yes/no). +#drop-short-scenes = no + +# 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 + + +# +# DETECTOR OPTIONS +# + +[detect-adaptive] +# Frame score threshold, refers to the `adaptive_ratio` metric in stats file. +#threshold = 3 + +# Minimum threshold that `content_val` metric from detect-content must exceed. +#min-content-val = 15 + +# Window size (number of frames) before and after each frame to average together. +#frame-window = 2 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + +# The following parameters are the those used to calculate `content_val`. +# See [detect-content] for detailed descriptions of these parameters. +#weights = 1.0, 1.0, 1.0, 0.0 +#luma-only = no +#kernel-size = -1 + + +[detect-content] +# Sensitivity threshold from 0 to 255. Lower values are more sensitive. +#threshold = 27 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + +# Mode to use when filtering scenes to comply with min-scene-len: +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge + +# Weight to place on each component when calculating frame score (the value +# `threshold` is compared against). The components are in the order +# (delta_hue, delta_sat, delta_lum, delta_edges). Description of components: +# - delta_hue: Difference between hue values of adjacent frames +# - delta_sat: Difference between saturation values of adjacent frames +# - delta_lum: Difference between luma/brightness values of adjacent frames +# - delta_edges: Difference between calculated edges of adjacent frames +# The score of each frame ('content_val' in the statsfile) is calculated as +# the weighted sum of all components. +#weights = 1.0 1.0 1.0 0.0 + +# Discard colour information and only use luminance (yes/no). +# If yes, overrides weights with (0.0, 0.0, 1.0, 0.0). +#luma-only = no + +# Size of kernel for expanding detected edges. Must be odd integer greater +# than or equal to 3. If None, automatically set using video resolution. +#kernel-size = -1 + +# Mode to use for enforcing min-scene-len: +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge + + +[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.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, +# 2 means keep lower 1/2 of frequency data, 4 means keep lower 1/4, etc... +#lowpass = 2 + +# Size between 1 and 256 representing size of square of low frequency data to +# use for the direct cosine transform (DCT). +#size = 8 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + + +[detect-hist] +# 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.20 + +# Number of bins between 1 and 256 to use for the histogram. +#bins = 128 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + + +[detect-threshold] +# Average pixel intensity from 0-255 at which a fade event is triggered. +#threshold = 12 + +# Percent from -100.0 to 100.0 of timecode skew for where cuts should be placed. +# -100 indicates start frame, +100 indicates end frame, and 0 is the center. +#fade-bias 0 + +# Generate a scene from the end of the last fade out to the end of the video. +#add-last-scene = yes + +# Discard colour information and only use luminance (yes/no). +#luma-only = no + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + +# +# COMMAND OPTIONS +# + +[split-video] +# Folder to output videos. Overrides [global] output option. +#output = /usr/tmp/encoded + +# Filename template to use as output. +#filename = $VIDEO_NAME-Scene-$SCENE_NUMBER + +# Suppress output from split tool. +#quiet = no + +# Use higher bitrate for better output quality (y/n), equivalent to setting +# rate-factor = 17 and preset = slow. +#high-quality = no + +# Use codec copying instead of encoding. Significantly faster, but can result +# in inaccurate splits due to keyframe positioning. +#copy = no + +# Use mkvmerge for copying instead of encoding. Has the same drawbacks as copy = yes. +#mkvmerge = no + +# x264 rate-factor, higher indicates lower quality / smaller filesize. +# 0 = lossless, 17 = visually identical, 22 = default. +#rate-factor = 22 + +# One of the ffmpeg x264 presets (e.g. veryfast, fast, medium, slow, slower). +#preset = veryfast + +# 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. +#output = /usr/tmp/images + +# Filename format of created images. Can use $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, +# $TIMECODE, $FRAME_NUMBER, and $TIMESTAMP_MS. Should not include extension. +#filename = $VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER + +# Image format (jpeg, png, webp). +#format = jpeg + +# Number of images to generate for each scene. +#num-images = 3 + +# Image quality (jpeg/webp). Default is 95 for jpeg, 100 for webp +#quality = 95 + +# Compression amount for png images (0 to 9). Only affects size, not quality. +#compression = 3 + +# Padding around each scene cut when selecting frames. Accepts a number of frames (1), +# seconds with `s` suffix (0.1s), or timecode (00:00:00.100). +#frame-margin = 1 + +# Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double). +#scale = 1.0 + +# Resize to specified height, width, or both. Mutually exclusive with scale. +#height = 0 +#width = 0 + +# 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 + + +[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 + +# Do not generate elements in resulting table (yes/no). +#no-images = no + + +[list-scenes] +# By default, list-scenes will create a CSV file. Enable this option +# to suppress creating the CSV file. +#no-output-file = no + +# Folder to output scene list. Overrides [global] output option. +#output = /usr/tmp/images + +# Filename format to use when saving scene list. $VIDEO_NAME can be used to +# represent the name of the video being processed. +#filename = $VIDEO_NAME-Scenes.csv + +# Display a table with the start/end boundaries for each scene (yes/no). +#display-scenes = yes + +# 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 + +# Skip writing cut points as the first row in the CSV file (yes/no). +# Set for RFC 4180 compliance. +#skip-cuts = no + +# Suppress all display output of list-scenes command. +# Overrides `display-scenes` and `display-cuts`. +#quiet = no + + +[load-scenes] +# Name of column used to mark scene cut points. +#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 +# + +[backend-opencv] +# Number of times to keep reading frames after one fails to decode. +# If set to 0, processing will stop on the first decode failure. +#max-decode-attempts = 5 + + +[backend-pyav] +# Threading mode to use (none, slice, frame, auto). Slice mode is the +# PyAV default, and auto/frame are the fastest. +#threading-mode = auto + +# Suppress ffmpeg log output. Default is `no`. +# +# WARNING: When threading-mode is set to auto/frame, setting +# `suppress-output = yes` can cause the the program to not exit properly +# on Linux/OSX (press Ctrl+C to quit if this occurs). +#suppress-output = no diff --git a/scenedetect.py b/scenedetect.py deleted file mode 100755 index 0db44cc3..00000000 --- a/scenedetect.py +++ /dev/null @@ -1,35 +0,0 @@ - -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# This is a convenience/backwards-compatibility script, and simply provides an -# alternative to running PySceneDetect from source (in addition to the standard -# python -m scenedetect). -# -# Copyright (C) 2012-2017 Brandon Castellano . -# -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file or visit one of the following pages for details: -# - http://www.bcastell.com/projects/pyscenedetect/ -# - https://github.com/Breakthrough/PySceneDetect/ -# -# This software uses Numpy and OpenCV; see the LICENSE-NUMPY and -# LICENSE-OPENCV files or visit one of above URLs for details. -# -# 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. -# - -if __name__ == "__main__": - # pylint: disable=no-name-in-module - from scenedetect.__main__ import main - main() diff --git a/scenedetect.spec b/scenedetect.spec deleted file mode 100644 index df5b612c..00000000 --- a/scenedetect.spec +++ /dev/null @@ -1,33 +0,0 @@ -# -*- mode: python -*- - -block_cipher = None - - -a = Analysis(['scenedetect/__main__.py'], - pathex=['.'], - binaries=None, - datas=[('./*.md', '.'), ('./docs/', 'docs/')], - 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 ) -coll = COLLECT(exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - name='scenedetect') diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index bc42c58f..ad2dc8dc 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -1,112 +1,213 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect `scenedetect` Module - -This is the main PySceneDetect module, containing imports of all classes -so they can be directly accessed from the scenedetect module in addition -to being directly imported (e.g. `from scenedetect import FrameTimecode` -is the same as `from scenedetect.frame_timecode import FrameTimecode`). - -This file also contains the PySceneDetect version string (displayed when calling -'scenedetect version'), the about string for license/copyright information -(when calling 'scenedetect about'). +"""The ``scenedetect`` module comes with helper functions to simplify common use cases. +:func:`detect` can be used to perform scene detection on a video by path. :func:`open_video` +can be used to open a video for a +:class:`SceneManager `. """ -# Standard Library Imports - -from __future__ import print_function -import sys -import os -import time - - -# PySceneDetect Library Imports - -# Commonly used classes for easier use directly from the scenedetect namespace (e.g. -# scenedetect.SceneManager instead of scenedetect.scene_manager.SceneManager). - +from logging import getLogger + +# OpenCV is a required package, but we don't have it as an explicit dependency since we +# need to support both opencv-python and opencv-python-headless. Include some additional +# context with the exception if this is the case. +try: + import cv2 as _ # 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", + name="cv2", + ) from ex + +# Commonly used classes/functions exported under the `scenedetect` namespace for brevity. +# 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 as ContentDetector, + AdaptiveDetector as AdaptiveDetector, + ThresholdDetector as ThresholdDetector, + HistogramDetector as HistogramDetector, + HashDetector as HashDetector, +) +from scenedetect.backends import ( + 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 as StatsManager +from scenedetect.stats_manager import StatsFileCorrupt as StatsFileCorrupt from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.video_manager import VideoManager -from scenedetect.detectors import ThresholdDetector, ContentDetector -from scenedetect.__main__ import main - - -# Used for module identification and when printing version & about info. -# (scenedetect version and scenedetect about) - -__version__ = 'v0.5' - -# About & copyright message string shown for the 'about' CLI command (scenedetect about). - -ABOUT_STRING = """ -Site/Updates: https://github.com/Breakthrough/PySceneDetect/ -Documentation: http://pyscenedetect.readthedocs.org/ - -Copyright (C) 2012-2018 Brandon Castellano. All rights reserved. - -PySceneDetect is released under the BSD 3-Clause license. See the -included LICENSE file or visit the PySceneDetect website for details. -This software uses the following third-party components: - - > NumPy [Copyright (C) 2018, Numpy Developers] - > OpenCV [Copyright (C) 2018, OpenCV Team] - > click [Copyright (C) 2018, Armin Ronacher] - -This software may also invoke the following third-party executables: - - > FFmpeg [Copyright (C) 2018, Fabrice Bellard] - > mkvmerge [Copyright (C) 2005-2016, Matroska] - -If included with your distribution of PySceneDetect, see the included -LICENSE-FFMPEG and LICENSE-MKVMERGE files for details. - -FFmpeg and mkvmerge are distributed only with certain PySceneDetect -releases, in order to allow for automatic video splitting capability. -If they were not included with your distribution, they can usually be -installed from your operating system's package manager, or downloaded -from the following URLs: - - FFmpeg: [ https://ffmpeg.org/download.html ] - mkvmerge: [ https://mkvtoolnix.download/downloads.html ] - (Note that mkvmerge is a part of the mkvtoolnix package.) - -Once installed, ensure the respective program can be accessed from the -same location running PySceneDetect by calling the `ffmpeg` or -`mkvmerge` command from a terminal/command prompt. - -PySceneDetect will automatically use whichever program is available on -the computer, depending on the specified command-line options. - -Additionally, certain Windows distributions may include a compiled -Python distribution. For license information regarding the distributed -version of Python, see the included LICENSE-PYTHON file for details, -or visit the following URL: [ https://docs.python.org/3/license.html ] - -THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED. -""" +# Used for module identification and when printing version & about info +# (e.g. calling `scenedetect version` or `scenedetect about`). +__version__ = "0.7.1" + +init_logger() +logger = getLogger("pyscenedetect") + + +def open_video( + 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. 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. + + Returns: + Backend object created with the specified video path. + + Raises: + :class:`VideoOpenFailure`: Constructing the VideoStream fails. If multiple backends have + been attempted, the error from the first backend will be returned. + """ + # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is + # used, once internal callers and downstream users have had a release to migrate. + if 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, 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: + raise + last_error = ex + else: + logger.warning("Backend %s not available.", backend) + # Fallback to OpenCV if `backend` is unavailable, or specified backend failed to open `path`. + backend_type = VideoStreamCv2 + logger.warning("Trying another backend: %s", backend_type.BACKEND_NAME) + try: + return backend_type(path, frame_rate) + except VideoOpenFailure as ex: + logger.debug("Failed to open video: %s", str(ex)) + if last_error is None: + last_error = ex + # Propagate any exceptions raised from specified backend, instead of errors from the fallback. + assert last_error is not None + raise last_error + + +def detect( + video_path: "StrPath | list[StrPath] | tuple[StrPath, ...]", + detector: SceneDetector, + stats_file_path: StrPath | None = None, + show_progress: bool = False, + start_time: TimecodeLike | None = None, + end_time: TimecodeLike | None = None, + start_in_scene: bool = False, + backend: str = "opencv", +) -> SceneList: + """Perform scene detection on a given video `path` using the specified `detector`. + + Arguments: + video_path: Path to input video (absolute or relative to working directory). 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 + determine a better threshold value. + show_progress: Show a progress bar with estimated time remaining. Default is False. + start_time: Starting point in video, in the form of a timecode ``HH:MM:SS[.nnn]`` (`str`), + number of seconds ``123.45`` (`float`), or number of frames ``200`` (`int`). + end_time: Starting point in video, in the form of a timecode ``HH:MM:SS[.nnn]`` (`str`), + number of seconds ``123.45`` (`float`), or number of frames ``200`` (`int`). + 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). + When detecting fades with `ThresholdDetector`, the beginning portion of the video + will always be included until the first fade-out event is detected. + backend: Name of the backend to use for video decoding. See + :data:`scenedetect.backends.AVAILABLE_BACKENDS` for backends available on the + current system. Defaults to OpenCV; falls back to OpenCV if the requested backend + is unavailable or fails to open the video. + + Returns: + List of scenes as pairs of (start, end) :class:`FrameTimecode` objects. + + Raises: + :class:`VideoOpenFailure`: `video_path` could not be opened. + :class:`StatsFileCorrupt`: `stats_file_path` is an invalid stats file + ValueError: `start_time` or `end_time` are incorrectly formatted. + TypeError: `start_time` or `end_time` are invalid types. + """ + video = open_video(video_path, backend=backend) + if start_time is not None: + 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) + scene_manager.add_detector(detector) + scene_manager.detect_scenes( + video=video, + show_progress=show_progress, + end_time=end_timecode, + ) + 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 6a0b40f6..f97bc320 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -1,63 +1,60 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect `scenedetect.__main__` Module - -Provides entry point for PySceneDetect's command-line interface (CLI) -functionality (in addition to using in other scripts via `import scenedetect`) -by installing the module and running the `scenedetect` command, or by calling: +"""Entry point for PySceneDetect's command-line interface.""" - > python -m scenedetect +import sys +from logging import getLogger -This module provides a high-level main() function, utilizing the scenedetect.cli -module, itself based on the click library, to provide command-line interface (CLI) -parsing functionality. Also note that a convenience script scenedetect.py is also -included for development purposes (allows ./scenedetect.py vs python -m scenedetect) +from scenedetect._cli import scenedetect +from scenedetect._cli.context import CliContext +from scenedetect._cli.controller import run_scenedetect +from scenedetect.platform import DEBUG_MODE, FakeTqdmLoggingRedirect, logging_redirect_tqdm -Installing PySceneDetect (using `python setup.py install` in the parent directory) -will also add the `scenedetect` command to %PATH% be used from anywhere. -""" - -# PySceneDetect Library Imports -from scenedetect.cli import CliContext -from scenedetect.cli import scenedetect_cli as cli def main(): - """ Main: PySceneDetect command-line interface (CLI) entry point. - - Passes control flow to the CLI parser (using the click library), whose - entry point is the decorated scenedetect.cli.scenedetect_cli function. - """ - - cli_ctx = CliContext() # CliContext object passed between CLI commands. + """PySceneDetect command-line interface (CLI) entry point.""" + context = CliContext() try: - # pylint: disable=unexpected-keyword-arg, no-value-for-parameter - cli.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. - finally: - cli_ctx.cleanup() - -if __name__ == '__main__': + # Process command line arguments and subcommands to initialize the context. + scenedetect.main(obj=context) # Parse CLI arguments with registered callbacks. + except SystemExit as exit: + help_command = any(arg in sys.argv for arg in ["-h", "--help"]) + if help_command or exit.code != 0: + raise + + # If we get here, processing the command line and loading the context worked. Let's run + # the controller if we didn't process any help requests. + logger = getLogger("pyscenedetect") + # Ensure log messages don't conflict with any progress bars. If we're in quiet mode, where + # no progress bars get created, we instead create a fake context manager. This is done here + # to avoid needing a separate context manager at each point a progress bar is created. + log_redirect = ( + FakeTqdmLoggingRedirect() if context.quiet_mode else logging_redirect_tqdm(loggers=[logger]) + ) + + with log_redirect: + try: + run_scenedetect(context) + except KeyboardInterrupt: + logger.info("Stopped.") + if DEBUG_MODE: + raise + raise SystemExit(1) from None + except BaseException as ex: + if DEBUG_MODE: + raise + logger.critical("ERROR: Unhandled exception:", exc_info=ex) + raise SystemExit(1) from ex + + +if __name__ == "__main__": main() diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py new file mode 100644 index 00000000..dd21768c --- /dev/null +++ b/scenedetect/_cli/__init__.py @@ -0,0 +1,1865 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""Implementation of the PySceneDetect application itself (the `scenedetect` command). The main CLI +entry-point function is :func:scenedetect_cli, which is a chained command group. + +Commands are first parsed into a context (`CliContext`), which is then passed to a controller which +performs scene detection and other required actions (`run_scenedetect`). +""" + +# Some parts of this file need word wrap to be displayed. + +import inspect +import logging +import os +import os.path +from copy import copy + +import click + +import scenedetect as scenedetect_pkg +import scenedetect._cli.commands as cli_commands +from scenedetect._cli.config import ( + CHOICE_MAP, + CONFIG_FILE_PATH, + CONFIG_MAP, + DEFAULT_JPG_QUALITY, + DEFAULT_WEBP_QUALITY, + RangeValue, +) +from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements +from scenedetect.backends import AVAILABLE_BACKENDS +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) +from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info + +PROGRAM_VERSION = scenedetect_pkg.__version__ +"""Used to avoid name conflict with named `scenedetect` command below.""" + +logger = logging.getLogger("pyscenedetect") + +LINE_SEPARATOR = "-" * 72 + + +def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": + """Return a `click` parameter type matching the `RangeValue` at `CONFIG_MAP[section][key]`. + + Used in `@click.option(... type=...)` decorators so each option's bounds and value type are + sourced from the canonical `CONFIG_MAP` entry. + """ + val = CONFIG_MAP[section][key] + assert isinstance(val, RangeValue), f"Expected RangeValue at {section}/{key}, got {type(val)}" + return val.click_range + + +# About & copyright message string shown for the 'about' CLI command (scenedetect about). +ABOUT_STRING = """ +Site: http://scenedetect.com/ +Docs: https://www.scenedetect.com/docs/ +Code: https://github.com/Breakthrough/PySceneDetect/ + +Copyright (C) 2014 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/ ]. +This software uses the following third-party components: + + > NumPy [Copyright (C) 2018, Numpy Developers] + > OpenCV [Copyright (C) 2018, OpenCV Team] + > click [Copyright (C) 2018, Armin Ronacher] + > simpletable [Copyright (C) 2014 Matheus Vieira Portela] + > PyAV [Copyright (C) 2017, Mike Boers and others] + > MoviePy [Copyright (C) 2015 Zulko] + +This software may also invoke the following third-party executables: + + > FFmpeg [Copyright (C) 2018, Fabrice Bellard] + > mkvmerge [Copyright (C) 2005-2016, Matroska] + +Certain distributions of PySceneDetect may include ffmpeg. See +the included LICENSE-FFMPEG or visit [ https://ffmpeg.org ]. + +Binary distributions of PySceneDetect include a compiled Python +distribution. See the included LICENSE-PYTHON file, or visit +[ https://docs.python.org/3/license.html ]. + +THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED. +""" + + +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(f"`{ctx.command.name}` Command", fg="cyan")) + formatter.write_paragraph() + formatter.write(click.style(LINE_SEPARATOR, fg="cyan")) + formatter.write_paragraph() + else: + 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_paragraph() + + self.format_usage(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Writes the help text to the formatter if it exists.""" + 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=f"{base_command} -i video.mp4" + ) + text = inspect.cleandoc(formatted_help).partition("\f")[0] + formatter.write_paragraph() + formatter.write_text(text) + + def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Writes the epilog into the formatter if it exists.""" + if self.epilog: + epilog = inspect.cleandoc(self.epilog) + formatter.write_paragraph() + formatter.write_text(epilog) + + +class CommandGroup(Command, click.Group): + """Custom formatting for command groups.""" + + pass + + +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 + click.echo("") + 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, + 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`. +@click.option( + "--input", + "-i", + multiple=False, + required=False, + metavar="VIDEO", + type=click.STRING, + help="[REQUIRED] Input video file. Image sequences and URLs are supported.", +) +@click.option( + "--output", + "-o", + multiple=False, + 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.{}".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=f"Path to config file. If unset, tries to load config from {CONFIG_FILE_PATH}", +) +@click.option( + "--stats", + "-s", + metavar="CSV", + type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), + help="Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.", +) +@click.option( + "--frame-rate", + "-f", + "frame_rate", + metavar="FPS", + type=click.FLOAT, + default=None, + 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", + "-m", + 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).{}".format( + USER_CONFIG.get_help_string("global", "min-scene-len") + ), +) +@click.option( + "--drop-short-scenes", + is_flag=True, + flag_value=True, + default=None, + help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.{}".format( + USER_CONFIG.get_help_string("global", "drop-short-scenes") + ), +) +@click.option( + "--merge-last-scene", + is_flag=True, + flag_value=True, + default=None, + help="Merge last scene with previous if shorter than -m/--min-scene-len.{}".format( + USER_CONFIG.get_help_string("global", "merge-last-scene") + ), +) +@click.option( + "--backend", + "-b", + 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: {}]{}".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", + "-d", + 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.{}".format( + USER_CONFIG.get_help_string("global", "downscale", show_default=False) + ), +) +@click.option( + "--frame-skip", + "-fs", + 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... {}".format( + USER_CONFIG.get_help_string("global", "frame-skip") + ), +) +@click.option( + "--verbosity", + "-v", + metavar="LEVEL", + type=click.Choice(CHOICE_MAP["global"]["verbosity"], False), + default=None, + 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"), + ), +) +@click.option( + "--logfile", + "-l", + metavar="FILE", + type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), + help="Save debug log to FILE. Appends to existing file if present.", +) +@click.option( + "--quiet", + "-q", + is_flag=True, + flag_value=True, + help="Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.", +) +@click.pass_context +def scenedetect( + ctx: click.Context, + 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, +): + 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, + frame_rate=frame_rate, + stats_file=stats, + 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, + stats=stats, + verbosity=verbosity, + ) + + +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, + type=click.STRING, +) +@click.pass_context +def help_command(ctx: click.Context, command_name: str): + """Print full help reference.""" + # TODO: Other commands still seem to run if this is specified. + assert 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:", + " {}".format(", ".join(sorted(all_commands))), + ] + raise click.BadParameter("\n".join(error_strs), param_hint="command") + click.echo("") + 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): + 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.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(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.pass_context +def version_command(ctx: click.Context): + """Print PySceneDetect version.""" + click.echo("") + click.echo(get_system_version_info()) + ctx.exit() + + +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", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).", +) +@click.option( + "--duration", + "-d", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.", +) +@click.option( + "--end", + "-e", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration", +) +@click.pass_context +def time_command( + ctx: click.Context, + start: str | None, + duration: str | None, + end: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + if duration is not None and end is not None: + raise click.BadParameter( + "Only one of --duration/-d or --end/-e can be specified, not both.", + param_hint="time", + ) + logger.debug("Setting video time:\n start: %s, duration: %s, end: %s", start, duration, end) + # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to + # match the default start number used by `ffmpeg` when saving frames as images. As such, + # we must correct start time if set as frames. See the test_cli_time* tests for for details. + ctx.start_time = ctx.parse_timecode(start, correct_pts=True) + ctx.end_time = ctx.parse_timecode(end) + ctx.duration = ctx.parse_timecode(duration) + if ctx.start_time and ctx.end_time and (ctx.start_time + 1) > ctx.end_time: + raise click.BadParameter("-e/--end time must be greater than -s/--start") + + +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_range("detect-content", "threshold"), + default=None, + help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-content", "threshold") + ), +) +@click.option( + "--weights", + "-w", + 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).{}".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.{}".format( + USER_CONFIG.get_help_string("detect-content", "luma-only") + ), +) +@click.option( + "--kernel-size", + "-k", + 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.{}".format( + USER_CONFIG.get_help_string("detect-content", "kernel-size") + ), +) +@click.option( + "--min-scene-len", + "-m", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" + % ( + "" + if USER_CONFIG.is_default("detect-content", "min-scene-len") + else USER_CONFIG.get_help_string("detect-content", "min-scene-len") + ), +) +@click.option( + "--filter-mode", + "-f", + 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: {}. {}".format( + ", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), + USER_CONFIG.get_help_string("detect-content", "filter-mode"), + ), +) +@click.pass_context +def detect_content_command( + ctx: click.Context, + threshold: float | None, + weights: tuple[float, float, float, float] | None, + luma_only: bool, + kernel_size: int | None, + min_scene_len: str | None, + filter_mode: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_content_params( + threshold=threshold, + luma_only=luma_only, + min_scene_len=min_scene_len, + weights=weights, + kernel_size=kernel_size, + filter_mode=filter_mode, + ) + ctx.add_detector(ContentDetector, detector_args) + + +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.{}'.format( + USER_CONFIG.get_help_string("detect-adaptive", "threshold") + ), +) +@click.option( + "--min-content-val", + "-c", + metavar="VAL", + type=click.FLOAT, + default=None, + 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", + "-f", + 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.{}".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".{}'.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".{}'.format( + USER_CONFIG.get_help_string("detect-content", "luma-only") + ), +) +@click.option( + "--kernel-size", + "-k", + 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.{}".format( + USER_CONFIG.get_help_string("detect-content", "kernel-size") + ), +) +@click.option( + "--min-scene-len", + "-m", + 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" + % ( + "" + if USER_CONFIG.is_default("detect-adaptive", "min-scene-len") + else USER_CONFIG.get_help_string("detect-adaptive", "min-scene-len") + ), +) +@click.pass_context +def detect_adaptive_command( + ctx: click.Context, + threshold: float | None, + min_content_val: float | None, + frame_window: int | None, + weights: tuple[float, float, float, float] | None, + luma_only: bool, + kernel_size: int | None, + min_scene_len: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_adaptive_params( + threshold=threshold, + min_content_val=min_content_val, + frame_window=frame_window, + luma_only=luma_only, + min_scene_len=min_scene_len, + weights=weights, + kernel_size=kernel_size, + ) + ctx.add_detector(AdaptiveDetector, detector_args) + + +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_range("detect-threshold", "threshold"), + default=None, + help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-threshold", "threshold") + ), +) +@click.option( + "--fade-bias", + "-f", + metavar="PERCENT", + type=_click_range("detect-threshold", "fade-bias"), + default=None, + help="Percent (%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.{}".format( + USER_CONFIG.get_help_string("detect-threshold", "fade-bias") + ), +) +@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.{}".format( + USER_CONFIG.get_help_string("detect-threshold", "add-last-scene") + ), +) +@click.option( + "--min-scene-len", + "-m", + 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" + % ( + "" + if USER_CONFIG.is_default("detect-threshold", "min-scene-len") + else USER_CONFIG.get_help_string("detect-threshold", "min-scene-len") + ), +) +@click.pass_context +def detect_threshold_command( + ctx: click.Context, + threshold: float | None, + fade_bias: float | None, + add_last_scene: bool, + min_scene_len: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_threshold_params( + threshold=threshold, + fade_bias=fade_bias, + add_last_scene=add_last_scene, + min_scene_len=min_scene_len, + ) + ctx.add_detector(ThresholdDetector, detector_args) + + +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_range("detect-hist", "threshold"), + default=None, + help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " + "values are more sensitive to changes.{}".format( + USER_CONFIG.get_help_string("detect-hist", "threshold") + ), +) +@click.option( + "--bins", + "-b", + metavar="NUM", + type=_click_range("detect-hist", "bins"), + default=None, + help="The number of bins to use for the histogram calculation.{}".format( + USER_CONFIG.get_help_string("detect-hist", "bins") + ), +) +@click.option( + "--min-scene-len", + "-m", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="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.%s" + % ( + "" + if USER_CONFIG.is_default("detect-hist", "min-scene-len") + else USER_CONFIG.get_help_string("detect-hist", "min-scene-len") + ), +) +@click.pass_context +def detect_hist_command( + ctx: click.Context, + threshold: float | None, + bins: int | None, + min_scene_len: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_hist_params( + threshold=threshold, bins=bins, min_scene_len=min_scene_len + ) + ctx.add_detector(HistogramDetector, detector_args) + + +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_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.{}".format( + USER_CONFIG.get_help_string("detect-hash", "threshold") + ) + ), +) +@click.option( + "--size", + "-s", + metavar="SIZE", + type=_click_range("detect-hash", "size"), + default=None, + help="Size of square of low frequency data to include from the discrete cosine transform.{}".format( + USER_CONFIG.get_help_string("detect-hash", "size") + ), +) +@click.option( + "--lowpass", + "-l", + metavar="FRAC", + 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...{}".format( + USER_CONFIG.get_help_string("detect-hash", "lowpass") + ) + ), +) +@click.option( + "--min-scene-len", + "-m", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="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.%s" + % ( + "" + if USER_CONFIG.is_default("detect-hash", "min-scene-len") + else USER_CONFIG.get_help_string("detect-hash", "min-scene-len") + ), +) +@click.pass_context +def detect_hash_command( + ctx: click.Context, + threshold: float | None, + size: int | None, + lowpass: int | None, + min_scene_len: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_hash_params( + threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len + ) + ctx.add_detector(HashDetector, detector_args) + + +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", + multiple=False, + metavar="FILE", + required=True, + type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=True), + help="Scene list to read cut information from.", +) +@click.option( + "--start-col-name", + "-c", + metavar="STRING", + type=click.STRING, + default=None, + 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: 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( + f"Could not load scenes, file does not exist: {input}", param_hint="-i/--input" + ) + ctx.load_scenes_input = input + ctx.load_scenes_column_name = ctx.config.get_value( + "load-scenes", "start-col-name", start_col_name + ) + + +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.{}".format( + USER_CONFIG.get_help_string("save-html", "filename") + ), +) +@click.option( + "--no-images", + "-n", + is_flag=True, + flag_value=True, + help="Do not include images with the result.{}".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.{}".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.{}".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 save_html_command( + ctx: click.Context, + filename: str | None, + no_images: bool, + image_width: int | None, + image_height: int | None, + show: bool, +): + if ctx.info_name == "export-html": + logger.warning("WARNING: export-html is deprecated, use save-html instead.") + ctx = ctx.obj + assert isinstance(ctx, CliContext) + # 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.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, 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.{}".format( + USER_CONFIG.get_help_string("list-scenes", "output", show_default=False) + ), +) +@click.option( + "--filename", + "-f", + 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).{}".format( + USER_CONFIG.get_help_string("list-scenes", "filename") + ), +) +@click.option( + "--no-output-file", + "-n", + is_flag=True, + flag_value=True, + 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, + 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, + 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: str | None, + filename: str | None, + no_output_file: bool | None, + quiet: bool | None, + skip_cuts: bool | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + list_scenes_args = { + "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"), + "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) + + +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.{}".format( + USER_CONFIG.get_help_string("split-video", "output", show_default=False) + ), +) +@click.option( + "--filename", + "-f", + 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).{}".format( + USER_CONFIG.get_help_string("split-video", "filename") + ), +) +@click.option( + "--quiet", + "-q", + is_flag=True, + flag_value=True, + 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.{}".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{}".format( + USER_CONFIG.get_help_string("split-video", "high-quality") + ), +) +@click.option( + "--rate-factor", + "-crf", + metavar="RATE", + default=None, + 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") + ), +) +@click.option( + "--preset", + "-p", + metavar="LEVEL", + default=None, + type=click.Choice(CHOICE_MAP["split-video"]["preset"]), + 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"), + ), +) +@click.option( + "--args", + "-a", + 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.{}'.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.{}".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: str | None, + filename: str | None, + quiet: bool, + copy: bool, + high_quality: bool, + rate_factor: int | None, + preset: str | None, + args: str | None, + mkvmerge: bool, + expand: bool, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + check_split_video_requirements(use_mkvmerge=mkvmerge) + assert ctx.video_stream is not None + if "%" in ctx.video_stream.path or "://" in ctx.video_stream.path: + error = "The split-video command is incompatible with image sequences/URLs." + raise click.BadParameter(error, param_hint="split-video") + + # 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") + high_quality = ctx.config.get_value("split-video", "high-quality") + rate_factor = ctx.config.get_value("split-video", "rate-factor") + preset = ctx.config.get_value("split-video", "preset") + args = ctx.config.get_value("split-video", "args") + + # Disallow certain combinations of options. + if mkvmerge or copy: + command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" + if high_quality: + raise click.BadParameter( + f"high-quality (-hq) cannot be used with {command}", + param_hint="split-video", + ) + if args: + raise click.BadParameter( + f"args (-a) cannot be used with {command}", param_hint="split-video" + ) + if rate_factor: + raise click.BadParameter( + f"rate-factor (crf) cannot be used with {command}", param_hint="split-video" + ) + if preset: + raise click.BadParameter( + f"preset (-p) cannot be used with {command}", param_hint="split-video" + ) + + # mkvmerge-Specific Options + if mkvmerge and copy: + logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") + + # ffmpeg-Specific Options + if copy: + args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" + elif not args: + if rate_factor is None: + rate_factor = 22 if not high_quality else 17 + if preset is None: + preset = "veryfast" if not high_quality else "slow" + args = ( + "-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" + ) + if filename: + logger.info("Output file name format: %s", filename) + + split_video_args = { + "name_format": ctx.config.get_value("split-video", "filename", filename), + "use_mkvmerge": mkvmerge, + "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) + + +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.{}".format( + USER_CONFIG.get_help_string("save-images", "output", show_default=False) + ), +) +@click.option( + "--filename", + "-f", + 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.{}".format( + USER_CONFIG.get_help_string("save-images", "filename") + ), +) +@click.option( + "--num-images", + "-n", + 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.{}".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).{}".format( + USER_CONFIG.get_help_string("save-images", "format", show_default=False) + ), +) +@click.option( + "--webp", + "-w", + is_flag=True, + flag_value=True, + help="Set output format to WebP", +) +@click.option( + "--quality", + "-q", + 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]{}".format( + USER_CONFIG.get_help_string("save-images", "quality", show_default=False) + ), +) +@click.option( + "--png", + "-p", + is_flag=True, + flag_value=True, + help="Set output format to PNG.", +) +@click.option( + "--compression", + "-c", + 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.{}".format( + USER_CONFIG.get_help_string("save-images", "compression") + ), +) +@click.option( + "-m", + "--frame-margin", + metavar="DURATION", + default=None, + type=click.STRING, + help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).{}".format( + USER_CONFIG.get_help_string("save-images", "frame-margin") + ), +) +@click.option( + "--scale", + "-s", + metavar="S", + default=None, + type=click.FLOAT, + 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", + "-H", + metavar="H", + default=None, + type=click.INT, + help="Height (pixels) of images.{}".format( + USER_CONFIG.get_help_string("save-images", "height", show_default=False) + ), +) +@click.option( + "--width", + "-W", + metavar="W", + default=None, + type=click.INT, + 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: 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, +): + 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." + logger.error(error_str) + raise click.BadParameter(error_str, param_hint="save-images") + num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) + if num_flags > 1: + logger.error(".") + raise click.BadParameter("Only one image type can be specified.", param_hint="save-images") + elif num_flags == 0: + image_format = ctx.config.get_value("save-images", "format").lower() + jpeg = image_format == "jpeg" + webp = image_format == "webp" + png = image_format == "png" + + if not any((scale, height, width)): + 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 = 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") + else ctx.config.get_value("save-images", "quality") + ) + compression = ctx.config.get_value("save-images", "compression", compression) + image_extension = "jpg" if jpeg else "png" if png else "webp" + valid_params = get_cv2_imwrite_params() + if image_extension not in valid_params or valid_params[image_extension] is None: + error_strs = [ + 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. ", + ] + logger.debug("\n".join(error_strs)) + raise click.BadParameter("\n".join(error_strs), param_hint="save-images") + output = ctx.config.get_value("save-images", "output", output) + + save_images_args = { + "encoder_param": compression if png else quality, + "frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin), + "height": height, + "image_extension": image_extension, + "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": output, + "scale": scale, + "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 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) + + +# ---------------------------------------------------------------------- +# CLI Sub-Command Registration +# ---------------------------------------------------------------------- + +# Informational +scenedetect.add_command(about_command) +scenedetect.add_command(help_command) +scenedetect.add_command(version_command) + +# Input +scenedetect.add_command(load_scenes_command) +scenedetect.add_command(time_command) + +# 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 new file mode 100644 index 00000000..740f38b3 --- /dev/null +++ b/scenedetect/_cli/commands.py @@ -0,0 +1,367 @@ +# +# 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. +# +"""Logic for PySceneDetect commands that operate on the result of the processing pipeline. + +In addition to the the arguments registered with the command, commands will be called with the +current command-line context, as well as the processing result (scenes and cuts). +""" + +import logging +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, + expand_scenes_to_bounds, +) + +logger = logging.getLogger("pyscenedetect") + + +def save_html( + context: CliContext, + scenes: SceneList, + cuts: CutList, + image_width: int, + image_height: int, + filename: str, + no_images: bool, + show: bool, +): + """Handles the `save-html` command.""" + assert context.video_stream is not None + (image_filenames, output) = ( + context.save_images_result + if context.save_images_result is not None + else (None, context.output) + ) + + 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) + write_scene_list_html( + output_html_filename=html_path, + scene_list=scenes, + cut_list=cuts, + 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, + 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 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, + ) + logger.info("Writing scene list to CSV file:\n %s", scene_list_path) + with open(scene_list_path, "w") as scene_list_file: + write_scene_list( + output_csv_file=scene_list_file, + 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: + return + # Print scene list. + if display_scenes: + logger.info( + """Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- +%s +-----------------------------------------------------------------------""", + "\n".join( + [ + 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) + ] + ), + ) + # Print cut list. + if cuts and display_cuts: + logger.info( + "Comma-separated timecode list:\n %s", + ",".join([cut_format.format(cut) for cut in cuts]), + ) + + +def save_images( + context: CliContext, + scenes: SceneList, + cuts: CutList, + num_images: int, + frame_margin: int, + image_extension: str, + encoder_param: int, + 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, + video=context.video_stream, + num_images=num_images, + frame_margin=frame_margin, + image_extension=image_extension, + encoder_param=encoder_param, + 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 `save-html` if required. + context.save_images_result = (images, output) + + +def split_video( + context: CliContext, + scenes: SceneList, + cuts: CutList, + name_format: str, + use_mkvmerge: bool, + 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(".") + extension_length = 0 if dot_pos < 0 else len(name_format) - (dot_pos + 1) + # If using mkvmerge, force extension to .mkv. + if use_mkvmerge and not name_format.endswith(".mkv"): + name_format += ".mkv" + # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. + elif not 2 <= extension_length <= 4: + name_format += ".mp4" + if use_mkvmerge: + split_video_mkvmerge( + input_video_path=context.video_stream.path, + scene_list=scenes, + output_dir=output, + output_file_template=name_format, + show_output=show_output, + ) + else: + split_video_ffmpeg( + input_video_path=context.video_stream.path, + scene_list=scenes, + output_dir=output, + output_file_template=name_format, + arg_override=ffmpeg_args, + show_progress=not context.quiet_mode, + show_output=show_output, + ) + 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 new file mode 100644 index 00000000..26787080 --- /dev/null +++ b/scenedetect/_cli/config.py @@ -0,0 +1,832 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""Handles loading configuration files from disk and validating each section. Only validation of the +config file schema and data types are performed. Constants/defaults are also defined here where +possible and re-used by the CLI so that there is one source of truth. +""" + +import logging +import os +import os.path +import typing as ty +from abc import ABC, abstractmethod +from configparser import ConfigParser +from configparser import Error as ConfigParserError +from enum import Enum + +import click +from platformdirs import user_config_dir + +from scenedetect.common import FrameTimecode +from scenedetect.detector import FlashFilter +from scenedetect.detectors import ContentDetector +from scenedetect.output.video import _DEFAULT_FFMPEG_ARGS +from scenedetect.platform import DEBUG_MODE +from scenedetect.scene_manager import Interpolation + +PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] + +LogMessage = tuple[int, str] + + +class OptionParseFailure(Exception): + """Raised when a value provided in a user config file fails validation.""" + + def __init__(self, error): + super().__init__() + self.error = error + + +class ValidatedValue(ABC): + """Used to represent configuration values that must be validated against constraints.""" + + @property + @abstractmethod + def value(self) -> ty.Any: + """Get the value after validation.""" + ... + + @staticmethod + @abstractmethod + def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue": + """Validate and get the user-specified configuration option. + + Raises: + OptionParseFailure: Value from config file did not meet validation constraints. + """ + ... + + def __repr__(self) -> str: + return str(self.value) + + def __str__(self) -> str: + return str(self.value) + + +class TimecodeValue(ValidatedValue): + """Validator for timecode values in seconds (100.0), frames (100), or HH:MM:SS. + + Stores value in original representation.""" + + 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) -> int | float | str: + return self._value + + @staticmethod + def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": + try: + return TimecodeValue(config_value) + except ValueError as ex: + raise OptionParseFailure( + "Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS." + ) from ex + + +class RangeValue(ValidatedValue): + """Validator for int/float ranges. `min_val` and `max_val` are inclusive.""" + + def __init__( + self, + value: int | float, + min_val: int | float, + max_val: int | float, + ): + if value < min_val or value > max_val: + # min and max are inclusive. + raise ValueError() + self._value = value + self._min_val = min_val + self._max_val = max_val + + @property + def value(self) -> int | float: + return self._value + + @property + def min_val(self) -> int | float: + """Minimum value of the range.""" + return self._min_val + + @property + def max_val(self) -> int | float: + """Maximum value of the range.""" + return self._max_val + + @property + def click_range(self) -> "click.IntRange | click.FloatRange": + """A `click` parameter type matching this range's bounds and value type.""" + if isinstance(self._value, int): + return click.IntRange(int(self._min_val), int(self._max_val)) + return click.FloatRange(float(self._min_val), float(self._max_val)) + + @staticmethod + def from_config(config_value: str, default: "RangeValue") -> "RangeValue": + try: + return RangeValue( + value=int(config_value) if isinstance(default.value, int) else float(config_value), + min_val=default.min_val, + max_val=default.max_val, + ) + except ValueError as ex: + raise OptionParseFailure( + 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 = (",", "/", "(", ")") + """Characters to ignore.""" + + def __init__(self, value: str | ContentDetector.Components): + if isinstance(value, ContentDetector.Components): + self._value = value + else: + translation_table = str.maketrans( + {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} + ) + values = value.translate(translation_table).split() + if not len(values) == 4: + raise ValueError("Score weights must be specified as four numbers!") + self._value = ContentDetector.Components(*(float(val) for val in values)) + + @property + def value(self) -> ContentDetector.Components: + return self._value + + def __str__(self) -> str: + return "{:.3f}, {:.3f}, {:.3f}, {:.3f}".format(*self.value) + + @staticmethod + def from_config(config_value: str, default: "ScoreWeightsValue") -> "ScoreWeightsValue": + try: + return ScoreWeightsValue(config_value) + except ValueError as ex: + raise OptionParseFailure( + "Score weights must be specified as four numbers in the form (H,S,L,E)," + " e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored." + ) from ex + + +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: + # 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() + else: + self._value = value + + @property + def value(self) -> int | None: + return self._value + + def __str__(self) -> str: + if self._value is None: + return "auto" + return str(self._value) + + @staticmethod + def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue": + try: + return KernelSizeValue(int(config_value)) + except ValueError as ex: + raise OptionParseFailure( + "Value must be an odd integer greater than 1, or set to -1 for auto kernel size." + ) 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.""" + + FRAMES = 0 + """Print timecodes as exact frame number.""" + TIMECODE = 1 + """Print timecodes in format HH:MM:SS.nnn.""" + SECONDS = 2 + """Print timecodes in seconds SSS.sss.""" + + def format(self, timecode: FrameTimecode) -> str: + if self == TimecodeFormat.FRAMES: + return str(timecode.frame_num) + if self == TimecodeFormat.TIMECODE: + return timecode.get_timecode() + if self == TimecodeFormat.SECONDS: + return f"{timecode.seconds:.3f}" + raise RuntimeError("Unhandled format specifier.") + + +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: 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: str = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) +DEFAULT_JPG_QUALITY = 95 +DEFAULT_WEBP_QUALITY = 100 + +CONFIG_MAP: ConfigDict = { + "backend-opencv": { + "max-decode-attempts": 5, + }, + "backend-pyav": { + "suppress-output": False, + "threading-mode": "auto", + }, + "detect-adaptive": { + "frame-window": 2, + "kernel-size": KernelSizeValue(-1), + "luma-only": False, + "min-content-val": 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": FlashFilter.Mode.MERGE, + "kernel-size": KernelSizeValue(-1), + "luma-only": False, + "min-scene-len": TimecodeValue(0), + "threshold": RangeValue(27.0, min_val=0.0, max_val=255.0), + "weights": ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), + }, + "detect-hash": { + "min-scene-len": TimecodeValue(0), + "lowpass": RangeValue(2, min_val=1, max_val=256), + "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.20, min_val=0.0, max_val=1.0), + "bins": RangeValue(128, min_val=1, max_val=256), + }, + "detect-threshold": { + "add-last-scene": True, + "fade-bias": RangeValue(0, min_val=-100.0, max_val=100.0), + "min-scene-len": TimecodeValue(0), + "threshold": RangeValue(12.0, min_val=0.0, max_val=255.0), + }, + "load-scenes": { + "start-col-name": "Start Frame", + }, + "list-scenes": { + "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": Interpolation.LINEAR, + "drop-short-scenes": False, + "frame-skip": 0, + "merge-last-scene": False, + "min-scene-len": TimecodeValue("0.6s"), + "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": TimecodeValue(1), + "height": 0, + "num-images": 3, + "output": None, + "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), + "scale": 1.0, + "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, + "copy": False, + "expand": False, + "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", + "high-quality": False, + "mkvmerge": False, + "output": None, + "preset": "veryfast", + "quiet": False, + "rate-factor": RangeValue(22, min_val=0, max_val=100), + }, +} +"""Mapping of valid configuration file parameters and their default values or placeholders. +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]]] = { + "backend-pyav": { + "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], + }, + "detect-content": { + "filter-mode": [mode.name.lower() for mode in FlashFilter.Mode], + }, + "global": { + "backend": ["opencv", "pyav", "moviepy"], + "default-detector": [ + "detect-adaptive", + "detect-content", + "detect-threshold", + "detect-hash", + "detect-hist", + ], + "downscale-method": [value.name.lower() for value in Interpolation], + "verbosity": ["debug", "info", "warning", "error", "none"], + }, + "list-scenes": { + "cut-format": [value.name.lower() for value in TimecodeFormat], + }, + "save-images": { + "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", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + ], + }, +} +"""Mapping of string options which can only be of a particular set of values. We use a list instead +of a set to preserve order when generating error contexts. Values are case-insensitive, and must be +in lowercase in this map.""" + +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 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: + config[command] = {} + for option in CONFIG_MAP[command]: + if command in parser and option in parser[command]: + # Bind to a local so pyright can narrow inside the isinstance branches. + default_value = CONFIG_MAP[command][option] + try: + value_type = None + if isinstance(default_value, bool): + value_type = "yes/no value" + config[command][option] = parser.getboolean(command, option) + continue + elif isinstance(default_value, int): + value_type = "integer" + config[command][option] = parser.getint(command, option) + continue + elif isinstance(default_value, float): + value_type = "number" + 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 _: + 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 = parser.get(command, option) + if isinstance(default_value, ValidatedValue): + option_type = type(default_value) + try: + config[command][option] = option_type.from_config( + config_value=config_value, default=default_value + ) + except OptionParseFailure as ex: + 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 = 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, + parser.get(command, option), + ", ".join(choice for choice in CHOICE_MAP[command][option]), + ), + ) + ) + continue + config[command][option] = config_value + continue + + 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: list[LogMessage], reason: Exception | None = None): + super().__init__() + self.init_log = init_log + self.reason = reason + + +class ConfigRegistry: + 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._initialized = False + + try: + self._load_from_disk(path) + self._initialized = True + + except ConfigLoadFailure as ex: + if throw_exception: + raise + # If we fail to load the user config file, ensure the object is flagged as + # uninitialized, and log the error so it can be dealt with if necessary. + self._init_log = ex.init_log + if ex.reason is not None: + self._init_log += [ + (logging.ERROR, "Error: {}".format(str(ex.reason).replace("\t", " "))), + ] + self._initialized = False + + @property + def config_dict(self) -> ConfigDict: + """Current configuration options that are set for each command.""" + return self._config + + @property + def initialized(self) -> bool: + """True if the ConfigRegistry was constructed without errors, False otherwise.""" + return self._initialized + + def get_init_log(self): + """Get initialization log. Consumes the log, so subsequent calls will return None.""" + init_log = self._init_log + self._init_log = [] + return init_log + + 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._log(logging.INFO, f"Loading config from file:\n {path}") + if not os.path.exists(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._log(logging.DEBUG, "User config file not found.") + return + path = CONFIG_FILE_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 (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). + (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.""" + return not (command in self._config and option in self._config[command]) + + def get_value( + self, + command: str, + option: str, + override: ty.Any = None, + ) -> ty.Any: + """Get the current setting or default value of the specified command option. + + Returns ``ty.Any`` because each (command, option) pair has a known concrete type at + the call site, but the union across all options is too wide to be useful as a return + annotation. Callers should know the expected type for the option they are reading. + """ + assert command in CONFIG_MAP and option in CONFIG_MAP[command] + default_value = CONFIG_MAP[command][option] + if override is not None: + value = override + elif command in self._config and option in self._config[command]: + value = self._config[command][option] + else: + value = 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: bool | None = None) -> str: + """Get a string to specify for the help text indicating the current command option value, + if set, or the default. + + Arguments: + command: A command name or, "global" for global options. + option: Command-line option to set within `command`. + show_default: Always show default value. Default is False for flag/bool values, + True otherwise. + """ + assert command in CONFIG_MAP and option in CONFIG_MAP[command] + is_flag = isinstance(CONFIG_MAP[command][option], bool) + if command in self._config and option in self._config[command]: + if is_flag: + value_str = "on" if self._config[command][option] else "off" + else: + value_str = str(self._config[command][option]) + 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 f" [default: {CONFIG_MAP[command][option]!s}]" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py new file mode 100644 index 00000000..e5cebb0f --- /dev/null +++ b/scenedetect/_cli/context.py @@ -0,0 +1,567 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""Context of which command-line options and config settings the user provided.""" + +import logging +import typing as ty + +import click + +import scenedetect # Required to access __version__ +from scenedetect import AVAILABLE_BACKENDS, open_video +from scenedetect._cli.config import ( + CHOICE_MAP, + ConfigLoadFailure, + ConfigRegistry, + CropValue, +) +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from scenedetect.detector import SceneDetector +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) +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_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream + +logger = logging.getLogger("pyscenedetect") + +USER_CONFIG = ConfigRegistry(throw_exception=False) +"""The user config, which can be overriden by command-line. If not found, will be default config.""" + + +def check_split_video_requirements(use_mkvmerge: bool) -> None: + """Validates that the proper tool is available on the system to perform the + `split-video` command. + + Arguments: + use_mkvmerge: True if mkvmerge (-m), False otherwise. + + Raises: click.BadParameter if the proper video splitting tool cannot be found. + """ + + if (use_mkvmerge and not is_mkvmerge_available()) or not is_ffmpeg_available(): + error_strs = [ + "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format( + EXTERN_TOOL="mkvmerge" if use_mkvmerge else "ffmpeg", + EXTRA_ARGS=" when mkvmerge (-m) is set" if use_mkvmerge else "", + ) + ] + error_strs += ["Ensure the program is available on your system and try again."] + if not use_mkvmerge and is_mkvmerge_available(): + error_strs += ["You can specify mkvmerge (-m) to use mkvmerge for splitting."] + elif use_mkvmerge and is_ffmpeg_available(): + error_strs += ["You can specify copy (-c) to use ffmpeg stream copying."] + error_str = "\n".join(error_strs) + raise click.BadParameter(error_str, param_hint="split-video") + + +class CliContext: + """The state of the application representing what video will be processed, how, and what to do + with the result. This includes handling all input options via command line and config file. + Once the CLI creates a context, it is executed by passing it to the + `scenedetect._cli.controller.run_scenedetect` function. + """ + + def __init__(self): + # State: + self.config: ConfigRegistry = USER_CONFIG + self.quiet_mode: bool | None = None + self.scene_manager: SceneManager | None = None + self.stats_manager: StatsManager | None = None + self.save_images: bool = False # True if the save-images command was specified + self.save_images_result: ty.Any = (None, None) # Result of save-images used by save-html + + # Input: + self.video_stream: VideoStream | None = 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 = 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: list[tuple[ty.Callable, dict[str, ty.Any]]] = [] + + def add_command(self, command: ty.Callable, command_args: dict[str, ty.Any]): + """Add `command` to the processing pipeline. Will be called after processing the input.""" + if "output" in command_args and command_args["output"] is None: + command_args["output"] = self.output + logger.debug("Adding command: %s(%s)", command.__name__, command_args) + self.commands.append((command, command_args)) + + def add_detector(self, detector: 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 _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. + + Raises: + click.BadParameter, click.ClickException + """ + if value is None: + return None + 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(): + 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" + ) from ex + + def handle_options( + self, + 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: 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 + `scenedetect [options] [commands [command options]]`). + + Raises: + click.BadParameter: One of the given options/parameters is invalid. + click.Abort: Fatal initialization failure. + """ + + # TODO(v1.0): Make the stats value optional (e.g. allow -s only), and allow use of + # $VIDEO_NAME macro in the name. Default to $VIDEO_NAME.csv. + + # 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() + quiet = not init_failure and quiet + self._initialize_logging(quiet, verbosity, logfile) + + # Configuration file was specified via CLI argument -c/--config. + if config and not init_failure: + self.config = ConfigRegistry(config) + init_log += self.config.get_init_log() + # Re-initialize logger with the correct verbosity. + if verbosity is None and not self.config.is_default("global", "verbosity"): + verbosity_str = self.config.get_value("global", "verbosity") + assert verbosity_str in CHOICE_MAP["global"]["verbosity"] + self.quiet_mode = False + self._initialize_logging(verbosity=verbosity_str, logfile=logfile) + + except ConfigLoadFailure as ex: + init_failure = True + init_log += ex.init_log + if ex.reason is not None: + 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__) + for log_level, log_str in init_log: + logger.log(log_level, log_str) + if init_failure: + logger.critical("Error processing configuration file.") + raise SystemExit(1) + + if 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: + error_strs = [ + "Unable to detect scenes with stats file if frame skip is not 0.", + " Either remove the -fs/--frame-skip option, or the -s/--stats file.\n", + ] + logger.error("\n".join(error_strs)) + raise click.BadParameter( + "Combining the -s/--stats and -fs/--frame-skip options is not supported.", + param_hint="frame skip + stats file", + ) + + # Handle case where -i/--input was not specified (e.g. for the `help` command). + if input_path is None: + return + + # Load the input video to obtain a time base for parsing timecodes. + self._open_video_stream(input_path, frame_rate, backend) + + 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 = self.config.get_value( + "global", "drop-short-scenes", drop_short_scenes + ) + 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) + + # Create StatsManager if --stats is specified. + if stats_file: + self.stats_file_path = stats_file + self.stats_manager = StatsManager() + + # Initialize default detector with values in the config file. + default_detector = self.config.get_value("global", "default-detector") + if default_detector == "detect-adaptive": + self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) + elif default_detector == "detect-content": + self.default_detector = (ContentDetector, self.get_detect_content_params()) + elif default_detector == "detect-hash": + self.default_detector = (HashDetector, self.get_detect_hash_params()) + elif default_detector == "detect-hist": + self.default_detector = (HistogramDetector, self.get_detect_hist_params()) + elif default_detector == "detect-threshold": + self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) + else: + raise click.BadParameter("Unknown detector type!", param_hint="default-detector") + + logger.debug("Initializing SceneManager.") + scene_manager = SceneManager(self.stats_manager) + + if downscale is None and self.config.is_default("global", "downscale"): + scene_manager.auto_downscale = True + else: + scene_manager.auto_downscale = False + downscale_value: int = self.config.get_value("global", "downscale", downscale) + try: + scene_manager.downscale = downscale_value + except ValueError as ex: + logger.debug(str(ex)) + 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 + + # + # Detector Parameters + # + + def get_detect_content_params( + self, + 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.""" + 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 + + return { + "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_frames, + "threshold": self.config.get_value("detect-content", "threshold", threshold), + "filter_mode": self.config.get_value("detect-content", "filter-mode", filter_mode), + } + + def get_detect_adaptive_params( + self, + threshold: 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.""" + + 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 { + "adaptive_threshold": self.config.get_value("detect-adaptive", "threshold", threshold), + "weights": self.config.get_value("detect-adaptive", "weights", weights), + "kernel_size": self.config.get_value("detect-adaptive", "kernel-size", kernel_size), + "luma_only": luma_only or self.config.get_value("detect-adaptive", "luma-only"), + "min_content_val": self.config.get_value( + "detect-adaptive", "min-content-val", min_content_val + ), + "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: 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.""" + + 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_frames, + "threshold": self.config.get_value("detect-threshold", "threshold", threshold), + } + + def get_detect_hist_params( + self, + 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.""" + + 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_frames, + "threshold": self.config.get_value("detect-hist", "threshold", threshold), + } + + def get_detect_hash_params( + self, + 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.""" + + 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_frames, + "size": self.config.get_value("detect-hash", "size", size), + "threshold": self.config.get_value("detect-hash", "threshold", threshold), + } + + # + # Private Methods + # + + def _initialize_logging( + self, + 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: + self.quiet_mode = bool(quiet) + curr_verbosity = logging.INFO + # Convert verbosity into it's log level enum, and override quiet mode if set. + if verbosity is not None: + assert verbosity in CHOICE_MAP["global"]["verbosity"] + if verbosity.lower() == "none": + self.quiet_mode = True + verbosity = "info" + else: + # Override quiet mode if verbosity is set. + self.quiet_mode = False + curr_verbosity = getattr(logging, verbosity.upper()) + else: + verbosity_str = USER_CONFIG.get_value("global", "verbosity") + assert verbosity_str in CHOICE_MAP["global"]["verbosity"] + if verbosity_str.lower() == "none": + self.quiet_mode = True + else: + curr_verbosity = getattr(logging, verbosity_str.upper()) + # Override quiet mode if verbosity is set. + if not USER_CONFIG.is_default("global", "verbosity"): + self.quiet_mode = False + # Initialize logger with the set CLI args / user configuration. + init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) + + def _open_video_stream( + self, + 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 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( + f"Specified backend {backend} is not available on this system!", + param_hint="-b/--backend", + ) + + # Open the video with the specified backend, loading any required config settings. + if backend == "pyav": + self.video_stream = open_video( + path=input_path, + 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"), + ) + elif backend == "opencv": + self.video_stream = open_video( + path=input_path, + frame_rate=frame_rate, + backend=backend, + max_decode_attempts=self.config.get_value( + "backend-opencv", "max-decode-attempts" + ), + ) + # Handle backends without any config options. + else: + self.video_stream = open_video( + path=input_path, + frame_rate=frame_rate, + backend=backend, + ) + duration = self.video_stream.duration + duration_str = f"{duration} ({duration.frame_num} frames)" if duration else "unknown" + rate = self.video_stream.frame_rate + logger.debug(f"""Video information: + Backend: {type(self.video_stream).__name__} + Resolution: {self.video_stream.frame_size} + 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 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{}: {}".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( + 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 new file mode 100644 index 00000000..dc8008f2 --- /dev/null +++ b/scenedetect/_cli/controller.py @@ -0,0 +1,223 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""Logic for the PySceneDetect command.""" + +import csv +import logging +import os +import time +import warnings + +from scenedetect._cli.context import CliContext +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 + +logger = logging.getLogger("pyscenedetect") + + +def run_scenedetect(context: CliContext): + """Perform main CLI application control logic. Run once all command-line options and + configuration file options have been validated. + + Arguments: + context: Prevalidated command-line option context to use for processing. + """ + # No input may have been specified depending on the commands/args that were used. + logger.debug("Running controller.") + if context.scene_manager is None: + 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) + if context.stats_file_path: + logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") + scenes, cuts = _load_scenes(context) + scenes = _postprocess_scene_list(context, scenes) + logger.info("Loaded %d scenes.", len(scenes)) + else: + # Perform scene detection on input. + result = _detect(context) + if result is None: + return + scenes, cuts = result + scenes = _postprocess_scene_list(context, scenes) + # Handle -s/--stats option. + _save_stats(context) + if scenes: + logger.info( + "Detected %d scenes, average shot length %.1f seconds.", + len(scenes), + sum([(end_time - start_time).seconds for start_time, end_time in scenes]) + / float(len(scenes)), + ) + else: + logger.info("No scenes detected.") + + # Handle post-processing commands the user wants to run (see scenedetect._cli.commands). + for handler, kwargs in context.commands: + handler(context=context, scenes=scenes, cuts=cuts, **kwargs) + + +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 + 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 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) -> 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: + logger.debug("Seeking to start time...") + try: + context.video_stream.seek(target=context.start_time) + except SeekError as ex: + logger.critical( + "Failed to seek to %s / frame %d: %s", + context.start_time.get_timecode(), + context.start_time.frame_num, + str(ex), + ) + return None + + num_frames = context.scene_manager.detect_scenes( + video=context.video_stream, + duration=context.duration, + end_time=context.end_time, + frame_skip=context.frame_skip, + show_progress=not context.quiet_mode, + ) + + # Handle case where video failure is most likely due to multiple audio tracks (#179). + # 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" + " pip install av\n" + "Or remove the audio tracks by running either:\n" + " ffmpeg -i input.mp4 -c copy -an output.mp4\n" + " mkvmerge -o output.mkv input.mp4\n" + "For details, see https://scenedetect.com/faq/" + ) + return None + + perf_duration = time.time() - perf_start_time + logger.info( + "Processed %d frames in %.1f seconds (average %.2f FPS).", + num_frames, + perf_duration, + float(num_frames) / perf_duration, + ) + + # Get list of detected cuts/scenes from the SceneManager to generate the required output + # files, based on the given commands (list-scenes, split-video, save-images, etc...). + cut_list = context.scene_manager.get_cut_list(show_warning=False) + scene_list = context.scene_manager.get_scene_list(start_in_scene=True) + + return scene_list, cut_list + + +def _save_stats(context: CliContext) -> None: + """Handles saving the statsfile if -s/--stats was specified.""" + if not context.stats_file_path: + return + assert context.stats_manager is not None + if context.stats_manager.is_save_required(): + path = get_and_create_path(context.stats_file_path, context.output) + logger.info("Saving frame metrics to stats file: %s", path) + with open(path, mode="w") as file: + context.stats_manager.save_to_csv(csv_file=file) + else: + logger.debug("No frame metrics updated, skipping update of the stats file.") + + +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 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) + + 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:] + + start_time = context.video_stream.base_timecode + if context.start_time is not None: + start_time = context.start_time + cut_list = [cut for cut in cut_list if cut > context.start_time] + + 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 (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/LICENSE-CLICK b/scenedetect/_thirdparty/LICENSE-CLICK similarity index 100% rename from LICENSE-CLICK rename to scenedetect/_thirdparty/LICENSE-CLICK diff --git a/scenedetect/_thirdparty/LICENSE-MOVIEPY b/scenedetect/_thirdparty/LICENSE-MOVIEPY new file mode 100644 index 00000000..1f7d430a --- /dev/null +++ b/scenedetect/_thirdparty/LICENSE-MOVIEPY @@ -0,0 +1,28 @@ +MoviePy license +Copyright (c) 2015 Zulko + +URL: https://github.com/Zulko/moviepy/blob/master/LICENCE.txt + +----------------------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2015 Zulko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. diff --git a/LICENSE-NUMPY b/scenedetect/_thirdparty/LICENSE-NUMPY similarity index 100% rename from LICENSE-NUMPY rename to scenedetect/_thirdparty/LICENSE-NUMPY diff --git a/LICENSE-OPENCV b/scenedetect/_thirdparty/LICENSE-OPENCV similarity index 100% rename from LICENSE-OPENCV rename to scenedetect/_thirdparty/LICENSE-OPENCV diff --git a/scenedetect/_thirdparty/LICENSE-PYAV b/scenedetect/_thirdparty/LICENSE-PYAV new file mode 100644 index 00000000..9db1661e --- /dev/null +++ b/scenedetect/_thirdparty/LICENSE-PYAV @@ -0,0 +1,23 @@ +Copyright retained by original committers. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the project nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSE-PYTEST b/scenedetect/_thirdparty/LICENSE-PYTEST similarity index 100% rename from LICENSE-PYTEST rename to scenedetect/_thirdparty/LICENSE-PYTEST diff --git a/scenedetect/_thirdparty/LICENSE-SIMPLETABLE b/scenedetect/_thirdparty/LICENSE-SIMPLETABLE new file mode 100644 index 00000000..52a65845 --- /dev/null +++ b/scenedetect/_thirdparty/LICENSE-SIMPLETABLE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Matheus Vieira Portela + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. diff --git a/LICENSE-TQDM b/scenedetect/_thirdparty/LICENSE-TQDM similarity index 100% rename from LICENSE-TQDM rename to scenedetect/_thirdparty/LICENSE-TQDM diff --git a/scenedetect/_thirdparty/__init__.py b/scenedetect/_thirdparty/__init__.py new file mode 100644 index 00000000..c7e79af1 --- /dev/null +++ b/scenedetect/_thirdparty/__init__.py @@ -0,0 +1,14 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""Includes third-party libraries distributed with PySceneDetect. To simplify distribution of binary +builds, the source directory also includes license files for the packages PySceneDetect depends on. +""" diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py new file mode 100644 index 00000000..f71c9cf5 --- /dev/null +++ b/scenedetect/_thirdparty/simpletable.py @@ -0,0 +1,326 @@ +#!/usr/bin/python + +# The MIT License (MIT) +# +# Copyright (c) 2014 Matheus Vieira Portela +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# 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 OR COPYRIGHT HOLDERS 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. +"""simpletable.py - v0.1 2014-07-31 Matheus Vieira Portela + +This module provides simple classes and interfaces to generate simple HTML +tables based on Python native types, such as lists. + +Author's website: http://matheusvportela.wordpress.com/ + +v0.4 2019-05-24 by Walter Schwenger +""" + +### CHANGES ### +# 2014-07-31: v0.1 MVP: +# - First version +# 2014-08-05: v0.2 MVP: +# - Method for defining header rows +# - SimpleTable method to create a SimpleTable from lists +# - Method to create a table from a simple list of elements and a column size +# 2014-08-20: v0.3 MVP: +# - Enable SimplePage to accept a list of tables +# - Enable SimplePage to iterate over its tables +# 2019-05-24: v0.4 WS: +# - Added SimpleTableImage class to handle adding images to tables +# - Added test images and image example to __main__ + +### REFERENCES ### +# Decalage HTML.py module: http://www.decalage.info/python/html + +import codecs + + +# noinspection PyCompatibility,PyUnresolvedReferences +def quote(string): + try: + from urllib.parse import quote + + return quote(string) + except ModuleNotFoundError: + from urllib import pathname2url + + return pathname2url(string) + + +class SimpleTableCell: + """A table class to create table cells. + + Example: + cell = SimpleTableCell('Hello, world!') + """ + + def __init__(self, text, header=False): + """Table cell constructor. + + Keyword arguments: + text -- text to be displayed + header -- flag to indicate this cell is a header cell. + """ + self.text = text + self.header = header + + def __str__(self): + """Return the HTML code for the table cell.""" + if self.header: + return "%s" % (self.text) + else: + return "%s" % (self.text) + + +class SimpleTableImage: + """A table class to create table cells with an image. + + Example: + cell = SimpleTableImage('images/image_1.jpg') + """ + + def __init__(self, image_file, width=None, height=None): + """Table cell constructor. + + Keyword arguments: + image_file -- relative filepath to image file to display. + width -- (optional) width of the image in pixels + height -- (optional) height of the image in pixels + """ + self.image_file = image_file + if width: + self.width = round(width) + else: + self.width = width + if height: + self.height = round(height) + else: + self.height = height + + def __str__(self): + """Return the HTML code for the table cell with the image.""" + safe_filename = quote(self.image_file) + output = '' % (safe_filename) + output += '") + + for cell in self.cells: + row.append(str(cell)) + + row.append("") + + return "\n".join(row) + + def __iter__(self): + """Iterate through row cells""" + yield from self.cells + + def add_cell(self, cell): + """Add a SimpleTableCell object to the list of cells.""" + self.cells.append(cell) + + def add_cells(self, cells): + """Add a list of SimpleTableCell objects to the list of cells.""" + for cell in cells: + self.cells.append(cell) + + +class SimpleTable: + """A table class to create HTML tables, populated by HTML table rows. + + Example: + # Table from lists + table = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']]) + + # Table with header row + table = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']], + header_row=['Header1', 'Header2', 'Header3']) + + # Table from SimpleTableRow + rows = SimpleTableRow(['Hello,', 'world!']) + table = SimpleTable(rows) + """ + + def __init__(self, rows=None, header_row=None, css_class=None): + """Table constructor. + + Keyword arguments: + rows -- iterable of SimpleTableRow + header_row -- row that will be displayed at the beginning of the table. + if this row is SimpleTableRow, it is the programmer's + responsibility to verify whether it was created with the + header flag set to True. + css_class -- table CSS class + """ + rows = rows or [] + if isinstance(rows[0], SimpleTableRow): + self.rows = rows + else: + self.rows = [SimpleTableRow(row) for row in rows] + + if header_row is None: + self.header_row = None + elif isinstance(header_row, SimpleTableRow): + self.header_row = header_row + else: + self.header_row = SimpleTableRow(header_row, header=True) + + self.css_class = css_class + + def __str__(self): + """Return the HTML code for the table as a string.""" + table = [] + + if self.css_class: + table.append("" % self.css_class) + else: + table.append("
") + + if self.header_row: + table.append(str(self.header_row)) + + for row in self.rows: + table.append(str(row)) + + table.append("
") + + return "\n".join(table) + + def __iter__(self): + """Iterate through table rows""" + yield from self.rows + + def add_row(self, row): + """Add a SimpleTableRow object to the list of rows.""" + self.rows.append(row) + + def add_rows(self, rows): + """Add a list of SimpleTableRow objects to the list of rows.""" + for row in rows: + self.rows.append(row) + + +class HTMLPage: + """A class to create HTML pages containing CSS and tables.""" + + def __init__(self, tables=None, css=None, encoding="utf-8"): + """HTML page constructor. + + Keyword arguments: + tables -- List of SimpleTable objects + css -- Cascading Style Sheet specification that is appended before the + table string + encoding -- Characters encoding. Default: UTF-8 + """ + self.tables = tables or [] + self.css = css + self.encoding = encoding + + def __str__(self): + """Return the HTML page as a string.""" + page = [] + + if self.css: + page.append('' % self.css) + + # Set encoding + page.append( + '' % self.encoding + ) + + for table in self.tables: + page.append(str(table)) + page.append("
") + + return "\n".join(page) + + def __iter__(self): + """Iterate through tables""" + yield from self.tables + + def save(self, filename): + """Save HTML page to a file using the proper encoding""" + with codecs.open(filename, "w", self.encoding) as outfile: + for line in str(self): + outfile.write(line) + + def add_table(self, table): + """Add a SimpleTable to the page list of tables""" + self.tables.append(table) + + +def fit_data_to_columns(data, num_cols): + """Format data into the configured number of columns in a proper format to + generate a SimpleTable. + + Example: + test_data = [str(x) for x in range(20)] + fitted_data = fit_data_to_columns(test_data, 5) + table = SimpleTable(fitted_data) + """ + num_iterations = len(data) / num_cols + + if len(data) % num_cols != 0: + num_iterations += 1 + + return [data[num_cols * i : num_cols * i + num_cols] for i in range(num_iterations)] diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py new file mode 100644 index 00000000..bad4b972 --- /dev/null +++ b/scenedetect/backends/__init__.py @@ -0,0 +1,127 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""``scenedetect.backends`` Module + +This module contains :class:`VideoStream ` implementations +backed by various Python multimedia libraries. In addition to creating backend objects directly, +:func:`scenedetect.open_video` can be used to open a video with a specified backend, falling +back to OpenCV if not available. + +All backends available on the current system can be found via :data:`AVAILABLE_BACKENDS`. + +If you already have a `cv2.VideoCapture` object you want to use for scene detection, you can +use a :class:`VideoCaptureAdapter ` instead +of a backend. This is useful when working with devices or streams, for example. + +=============================================================== +Video Files +=============================================================== + +Assuming we have a file `video.mp4` in our working directory, we can load it and perform scene +detection on it using :func:`open_video`: + +.. code:: python + + from scenedetect import open_video + 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` +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. + +Lastly, to use a specific backend directly: + +.. code:: python + + # Manually importing and constructing a backend: + from scenedetect.backends.opencv import VideoStreamCv2 + 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 +=============================================================== + +You can use an existing `cv2.VideoCapture` object with the PySceneDetect API using a +:class:`VideoCaptureAdapter `. For example, +to use a :class:`SceneManager ` with a webcam device: + +.. code:: python + + from scenedetect import SceneManager, ContentDetector + from scenedetect.backends import VideoCaptureAdapter + # Open device ID 2. + cap = cv2.VideoCapture(2) + video = VideoCaptureAdapter(cap) + total_frames = 1000 + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video=video, duration=total_frames) + +When working with live inputs, note that you can pass a callback to +:meth:`detect_scenes() ` to be +called on every scene detection event. See the :mod:`SceneManager ` +examples for details. +""" + +# TODO(v1.0): Consider removing and making this a namespace package so that additional backends can +# be dynamically added. The preferred approach for this should probably be: +# https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-namespace-packages + +# TODO: Future VideoStream implementations under consideration: +# - Nvidia VPF: https://developer.nvidia.com/blog/vpf-hardware-accelerated-video-processing-framework-in-python/ + +# OpenCV must be available at minimum. +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 as VideoStreamAv +except ImportError: + VideoStreamAv = None + +try: + 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] = { + backend.BACKEND_NAME: backend + for backend in filter( + None, + [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + ], + ) +} +"""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, 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 new file mode 100644 index 00000000..aef9844d --- /dev/null +++ b/scenedetect/backends/moviepy.py @@ -0,0 +1,295 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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:`VideoStreamMoviePy` provides an adapter for MoviePy's `FFMPEG_VideoReader`. + +MoviePy launches ffmpeg as a subprocess, and can be used with various types of inputs. Generally, +the input should support seeking, but does not necessarily have to be a video. For example, +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 + +import cv2 +import numpy as np +from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader + +from scenedetect.backends.opencv import VideoStreamCv2 +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: StrPath, + frame_rate: FrameRate | None = None, + print_infos: bool = False, + framerate: float | None = None, + ): + """Open a video or device. + + Arguments: + path: Path to video,. + 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. + 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 = _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: 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 = None + + # + # VideoStream Methods/Properties + # + + BACKEND_NAME = "moviepy" + """Unique name used to identify this backend.""" + + @property + 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) -> str: + """Video path.""" + return self._path + + @property + def name(self) -> str: + """Name of the video, without extension, or device.""" + return get_file_name(self.path, include_extension=False) + + @property + def is_seekable(self) -> bool: + """True if seek() is allowed, False otherwise.""" + return True + + @property + 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) -> 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"] + + @property + def aspect_ratio(self) -> float: + """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" + # 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. + try: + self._aspect_ratio = VideoStreamCv2(self._path).aspect_ratio + except VideoOpenFailure as ex: + logger.warning("Unable to determine aspect ratio: %s", str(ex)) + self._aspect_ratio = 1.0 + return self._aspect_ratio + + @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`. 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) + # 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: + """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.seconds * 1000.0 + + @property + def frame_number(self) -> int: + """Current position within stream in frames as an int. + + 0 indicates that no frames have been `read`, 1 indicates the first frame was just read. + """ + return self._frame_number + + 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). + + 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). + """ + success = False + if not isinstance(target, FrameTimecode): + target = FrameTimecode(target, self.frame_rate) + duration = self.duration + assert duration is not None + try: + self._last_frame = _retry_on_oserror( + "seek", lambda: self._reader.get_frame(target.seconds) + ) + if hasattr(self._reader, "last_read") and target >= 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: + # 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 + finally: + # Leave the object in a valid state on any errors. + if not success: + self.reset() + + def reset(self, print_infos=False): + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + 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) -> np.ndarray | bool: + if not hasattr(self._reader, "lastread") or self._eof: + return False + 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 and isinstance(self._last_frame, np.ndarray): + self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) + assert self._last_frame_rgb is not None + return self._last_frame_rgb + return not self._eof diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py new file mode 100644 index 00000000..12294664 --- /dev/null +++ b/scenedetect/backends/opencv.py @@ -0,0 +1,538 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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:`VideoStreamCv2` is backed by the OpenCV `VideoCapture` object. This is the default +backend. Works with video files, image sequences, and network streams/URLs. + +For wrapping input devices or pipes, there is also :class:`VideoCaptureAdapter` which can be +constructed from an existing `cv2.VideoCapture`. This allows performing scene detection on inputs +which do not support seeking. +""" + +import math +import os +import os.path +import warnings +from fractions import Fraction +from logging import getLogger + +import cv2 +import numpy as np + +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") + +IMAGE_SEQUENCE_IDENTIFIER = "%" + +NON_VIDEO_FILE_INPUT_IDENTIFIERS = ( + IMAGE_SEQUENCE_IDENTIFIER, # image sequence + "://", # URL/network stream + " ! ", # gstreamer pipe +) + + +def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float: + """Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels).""" + # Versions of OpenCV < 3.4.1 do not support this, so we fall back to 1.0. + if "CAP_PROP_SAR_NUM" not in dir(cv2): + return 1.0 + num: float = cap.get(cv2.CAP_PROP_SAR_NUM) + den: float = cap.get(cv2.CAP_PROP_SAR_DEN) + # If numerator or denominator are close to zero, so we fall back to 1.0. + if abs(num) < epsilon or abs(den) < epsilon: + return 1.0 + return num / den + + +class VideoStreamCv2(VideoStream): + """OpenCV `cv2.VideoCapture` backend.""" + + def __init__( + self, + path: StrPath | None = None, + frame_rate: FrameRate | None = None, + max_decode_attempts: int = 5, + 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. + 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 + of detection algorithms may be lower). Once this limit is passed, + 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 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 + if path_or_device is not 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!") + 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: str | int = resolved + self._is_device = isinstance(self._path_or_device, int) + + # VideoCapture state + self._has_grabbed = False + self._max_decode_attempts = max_decode_attempts + self._decode_failures = 0 + self._warning_displayed = False + + # `_open_capture` populates `_cap` and `_frame_rate`. + self._open_capture(frame_rate) + + # + # Backend-Specific Methods/Properties + # + + @property + def capture(self) -> cv2.VideoCapture: + """Returns reference to underlying VideoCapture object. Use with caution. + + Prefer to use this property only to take ownership of the underlying cv2.VideoCapture object + backing this object. Seeking or using the read/grab methods through this property are + unsupported and will leave this object in an inconsistent state. + """ + return self._cap + + # + # VideoStream Methods/Properties + # + + BACKEND_NAME = "opencv" + """Unique name used to identify this backend.""" + + @property + def frame_rate(self) -> Fraction: + return self._frame_rate + + @property + def path(self) -> str: + if self._is_device: + 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: + if self._is_device: + return self.path + file_name: str = get_file_name(self.path, include_extension=False) + if IMAGE_SEQUENCE_IDENTIFIER in file_name: + # file_name is an image sequence, trim everything including/after the %. + # TODO: This excludes any suffix after the sequence identifier. + file_name = file_name[: file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] + return file_name + + @property + def is_seekable(self) -> bool: + """True if seek() is allowed, False otherwise. + + Always False if opening a device/webcam.""" + return not self._is_device + + @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.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) + + @property + def duration(self) -> FrameTimecode | None: + """Duration of the stream as a FrameTimecode, or None if non terminating.""" + if self._is_device: + return None + return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + @property + def aspect_ratio(self) -> float: + """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" + return _get_aspect_ratio(self._cap) + + @property + 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) + + @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: + return self._cap.get(cv2.CAP_PROP_POS_MSEC) + + @property + def frame_number(self) -> int: + return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) + + 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!") + target_secs = (self.base_timecode + target).seconds + self._has_grabbed = False + 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 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(float(self._frame_rate)) + + def read(self, decode: bool = True) -> np.ndarray | bool: + if not self._cap.isOpened(): + return False + has_grabbed = self._cap.grab() + # If we failed to grab the frame, retry a few times if required. + if not has_grabbed: + 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() + return frame + return self._has_grabbed + + # + # Private Methods + # + + def _open_capture(self, frame_rate: FrameRate | None = None): + """Opens capture referenced by this object and resets internal state.""" + 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(): + raise VideoOpenFailure( + "Ensure file is valid video and system dependencies are up to date.\n" + ) + + # Display an error if the video codec type seems unsupported (#86) as this indicates + # potential video corruption, or may explain missing frames. We only perform this check + # for video files on-disk (skipped for devices, image sequences, streams, etc...). + codec_unsupported: bool = int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0 + if codec_unsupported and input_is_video_file: + logger.error( + "Video codec detection failed. If output is incorrect:\n" + " - Re-encode the input video with ffmpeg\n" + " - Update OpenCV (pip install --upgrade opencv-python)\n" + " - Use the PyAV backend (--backend pyav)\n" + "For details, see https://github.com/Breakthrough/PySceneDetect/issues/86" + ) + + # 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 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: 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 + + +class VideoCaptureAdapter(VideoStream): + """Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports + VideoCaptures which may not support seeking. + """ + + def __init__( + self, + cap: cv2.VideoCapture, + 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. + + Arguments: + cap: The `cv2.VideoCapture` object to wrap. Must already be opened and ready to + have `cap.read()` called on it. + 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, frame rate or max_read_attempts 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 + 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 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: Fraction = framerate_to_fraction(frame_rate) + self._num_frames = 0 + self._max_read_attempts = max_read_attempts + self._decode_failures = 0 + self._warning_displayed = False + self._time_base: float = 0.0 + + # + # Backend-Specific Methods/Properties + # + + @property + def capture(self) -> cv2.VideoCapture: + """Returns reference to underlying VideoCapture object. Use with caution. + + Prefer to use this property only to take ownership of the underlying cv2.VideoCapture object + backing this object. Using the read/grab methods through this property are unsupported and + will leave this object in an inconsistent state. + """ + return self._cap + + # + # VideoStream Methods/Properties + # + + BACKEND_NAME = "opencv_adapter" + """Unique name used to identify this backend.""" + + @property + def frame_rate(self) -> Fraction: + """Framerate in frames/sec.""" + return self._frame_rate + + @property + def path(self) -> str: + """Always 'CAP_ADAPTER'.""" + return "CAP_ADAPTER" + + @property + def name(self) -> str: + """Always 'CAP_ADAPTER'.""" + return "CAP_ADAPTER" + + @property + def is_seekable(self) -> bool: + """Always False, as the underlying VideoCapture is assumed to not support seeking.""" + return False + + @property + 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)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) + + @property + 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: + return self.base_timecode + frame_count + return None + + @property + def aspect_ratio(self) -> float: + """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" + return _get_aspect_ratio(self._cap) + + @property + def position(self) -> FrameTimecode: + if self.frame_number < 1: + return self.base_timecode + # 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: + 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: + return self._num_frames + + def seek(self, target: TimecodeLike): + """The underlying VideoCapture is assumed to not support seeking.""" + raise NotImplementedError("Seeking is not supported.") + + def reset(self): + """Not supported.""" + raise NotImplementedError("Reset is not supported.") + + def read(self, decode: bool = True) -> np.ndarray | bool: + if not self._cap.isOpened(): + return False + has_grabbed = self._cap.grab() + # If we failed to grab the frame, retry a few times if required. + if not has_grabbed: + for _ in range(self._max_read_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 + 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() + return frame + return True diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py new file mode 100644 index 00000000..0933c547 --- /dev/null +++ b/scenedetect/backends/pyav.py @@ -0,0 +1,436 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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 + +import av +import numpy as np + +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameRate, + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) +from scenedetect.platform import StrPath, get_file_name +from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream + +logger = getLogger("pyscenedetect") +VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] + +MAX_CONSECUTIVE_DECODE_FAILURES = 8 +"""Number of consecutive frame decode failures after which `VideoStreamAv.read()` gives up. +Isolated corrupt frames are skipped; this bound ensures a truncated file still terminates.""" + + +class VideoStreamAv(VideoStream): + """PyAV `av.InputContainer` backend.""" + + # TODO: Investigate adding an accurate_duration option to backends to calculate the duration + # with higher precision. Sometimes it doesn't exactly match what the codec or VLC reports, + # but we can try to seek to the end of the video first to determine it. Investigate how VLC + # calculates the end time. + def __init__( + self, + 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. + + .. warning:: + + Using `threading_mode` with `suppress_output = True` can cause lockups in your + application. See the PyAV documentation for details: + https://pyav.org/docs/stable/overview/caveats.html#sub-interpeters + + Arguments: + path_or_io: Path to the video, or a file-like object. + 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 + for valid threading modes ('AUTO', 'FRAME', 'NONE', and 'SLICE'). If this mode is + 'AUTO' or 'FRAME' and not all frames have been decoded, the video will be reopened + if seekable, and the remaining frames decoded in single-threaded mode. + suppress_output: If False, ffmpeg output will be sent to stdout/stderr by calling + `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 frame rate is invalid + """ + # 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__() + + # 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: av.VideoFrame | None = None + self._decoder: ty.Generator | None = None + self._reopened = True + self._decode_failures = 0 + self._warning_displayed = False + + if threading_mode: + try: + threading_mode = av.codec.context.ThreadType[threading_mode.upper()] # 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() # type: ignore[attr-defined] + + try: + 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) + else: + self._io = path_or_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 + logger.debug("Threading mode set: %s", threading_mode) + except OSError: + raise + except Exception as ex: + raise VideoOpenFailure(str(ex)) from ex + + 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 detected_rate is None or detected_rate == 0: + raise FrameRateUnavailable() + if detected_rate < MAX_FPS_DELTA: + raise FrameRateUnavailable() + self._frame_rate: Fraction = framerate_to_fraction(detected_rate) + else: + 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): + # 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 + # + + BACKEND_NAME = "pyav" + """Unique name used to identify this backend.""" + + @property + def path(self) -> str: + """Video path.""" + return self._path + + @property + def name(self) -> str: + """Name of the video, without extension.""" + return self._name + + @property + def is_seekable(self) -> bool: + """True if seek() is allowed, False otherwise.""" + return self._io.seekable() + + @property + 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) + + @property + def duration(self) -> FrameTimecode: + """Duration of the video as a FrameTimecode.""" + return self.base_timecode + self._duration_frames + + @property + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction.""" + return self._frame_rate + + @property + def position(self) -> FrameTimecode: + """Current position within stream as 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 or self._frame.pts is None or self._frame.time_base is None: + return self.base_timecode + 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 or self._frame.pts is None or self._frame.time_base is None: + return 0.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 (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 + + @property + def time_base(self) -> Fraction | None: + if self._frame: + return self._frame.time_base + return None + + @property + def aspect_ratio(self) -> float: + """Pixel aspect ratio as a float (1.0 represents square pixels).""" + if ( + not hasattr(self._codec_context, "display_aspect_ratio") + or self._codec_context.display_aspect_ratio is None + ): + return 1.0 + ar_denom = self._codec_context.display_aspect_ratio.denominator + if ar_denom <= 0: + return 1.0 + display_aspect_ratio = self._codec_context.display_aspect_ratio.numerator / ar_denom + assert self.frame_size[0] > 0 and self.frame_size[1] > 0 + frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] + return display_aspect_ratio / frame_aspect_ratio + + 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). + + 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. + + May not be supported on all input codecs (see `is_seekable`). + + Arguments: + target: Target position in video stream to seek to. + If float, interpreted as time in seconds. + If int, interpreted as frame number. + 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).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) + while self.position < target: + 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) -> np.ndarray | bool: + consecutive_failures = 0 + while True: + # Reuse a persistent decoder generator so the codec's internal frame buffer (used for + # B-frame reordering) is never flushed prematurely. Creating a new generator each call + # caused the last buffered frame to be lost at EOF. + if self._decoder is None: + self._decoder = self._container.decode(video=0) + try: + last_frame = self._frame + assert self._decoder is not None + self._frame = next(self._decoder) + # NOTE: EOFError subclasses FFmpegError, so this clause must come first. + except av.error.EOFError: # type: ignore[attr-defined] + self._frame = last_frame + if self._handle_eof(): + return self.read(decode) + return False + except StopIteration: + return False + except av.error.FFmpegError as ex: # type: ignore[attr-defined] + # `next()` raised before assignment, so `self._frame` is still the last good + # frame and position/frame_number are unaffected by the skipped packet. + self._decode_failures += 1 + consecutive_failures += 1 + # The decoder generator is closed once an exception propagates through it; + # recreating it (next loop iteration) resumes demuxing after the bad packet. + self._decoder = None + if consecutive_failures >= MAX_CONSECUTIVE_DECODE_FAILURES: + logger.error( + "Failed to decode %d consecutive frames, stopping: %s", + consecutive_failures, + ex, + ) + return False + logger.debug("Frame failed to decode: %s", ex) + if not self._warning_displayed and self._decode_failures > 1: + self._warning_displayed = True + logger.warning("Failed to decode some frames, results may be inaccurate.") + continue + assert self._frame is not None + return self._frame.to_ndarray(format="bgr24") if decode else True + + # + # Private Methods/Properties + # + + @property + def _video_stream(self): + """PyAV `av.video.stream.VideoStream` being used.""" + return self._container.streams.video[0] + + @property + 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 + # handle time calculations internally and which time base to use. + assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!" + # See if we can obtain the number of frames directly from the stream itself. + if self._video_stream.frames > 0: + return self._video_stream.frames + # Calculate based on the reported container duration. + duration_sec = None + container = self._video_stream.container + if container.duration is not None and container.duration > 0: + # Containers use AV_TIME_BASE as the time base. + duration_sec = float(self._video_stream.container.duration / av.time_base) + # Lastly, if that calculation fails, try to calculate it based on the stream duration. + if duration_sec is None or duration_sec < MAX_FPS_DELTA: + if self._video_stream.duration is None: + logger.warning("Video duration unavailable.") + return 0 + # Streams use stream `time_base` as the time base. + time_base = self._video_stream.time_base + if time_base.denominator == 0: + logger.warning( + "Unable to calculate video duration: time_base (%s) has zero denominator!", + str(time_base), + ) + return 0 + duration_sec = float(self._video_stream.duration / time_base) + return round(duration_sec * self.frame_rate) + + def _handle_eof(self): + """Fix for issue where if thread_type is 'AUTO' the whole video is not decoded. + + Re-open video if the threading mode is AUTO and we didn't decode all of the frames.""" + # Don't re-open the video if we already did, or if we already decoded all the frames. + if self._reopened or self.frame_number >= self.duration: + return False + self._reopened = True + # 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_pos_secs = self.position.seconds + orig_pos = self._io.tell() + try: + self._io.seek(0) + container = av.open(self._io) + except: + self._io.seek(orig_pos) + raise + self._container.close() + self._container = container + self._decoder = None + self.seek(last_pos_secs) + return True diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py deleted file mode 100644 index baee29e5..00000000 --- a/scenedetect/cli/__init__.py +++ /dev/null @@ -1,718 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect scenedetect.cli Module - -This file contains the implementation of the PySceneDetect command-line -interface (CLI) parser logic for the PySceneDetect application ("business logic"), -which uses the click library. The main CLI entry-point function is the -function scenedetect_cli, which is a chained command group. - -The scenedetect.cli module coordinates first parsing all actions to take and -their validity, storing them in the CliContext, finally performing scene -detection only after the input videos have been loaded and all CLI arguments -parsed and validated. - -Some of this parsing functionality is shared between the scenedetect.cli -module and the scenedetect.cli.CliContext object. -""" - -# Standard Library Imports -from __future__ import print_function -import sys -import string -import logging - -# Third-Party Library Imports -import click - -# PySceneDetect Library Imports -import scenedetect -from scenedetect.cli.context import CliContext -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.video_manager import VideoManager - -from scenedetect.video_splitter import is_mkvmerge_available -from scenedetect.video_splitter import is_ffmpeg_available - - -def get_help_command_preface(command_name='scenedetect'): - """ Preface/intro help message shown at the beginning of the help command. """ - return """ -The PySceneDetect command-line interface is grouped into commands which -can be combined together, each containing its own set of arguments: - - > {command_name} ([options]) [command] ([options]) ([...other command(s)...]) - -Where [command] is the name of the command, and ([options]) are the -arguments/options associated with the command, if any. Options -associated with the {command_name} command below (e.g. --input, ---framerate) must be specified before any commands. The order of -commands is not strict, but each command should only be specified once. - -Commands can also be combined, for example, running the 'detect-content' -and 'list-scenes' (specifying options for the latter): - - > {command_name} -i vid0001.mp4 detect-content list-scenes -n - -A list of all commands is printed below. Help for a particular command -can be printed by specifying 'help [command]', or 'help all' to print -the help information for every command. - -Lastly, there are several commands used for displaying application -version and copyright information (e.g. {command_name} about): - - version: Displays the version of PySceneDetect being used. - about: Displays PySceneDetect license and copyright information. -""".format(**{'command_name': command_name}) - - -CLICK_CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) - -COMMAND_DICT = [] - - -def add_cli_command(cli, command): - # type: (Callable[[...] -> None], Callable[]) -> None - """Adds the CLI command to the cli object as well as to the COMMAND_DICT.""" - cli.add_command(command) - COMMAND_DICT.append(command) - - -def parse_timecode(cli_ctx, value): - # type: (CliContext, str) -> Union[FrameTimecode, None] - """ Parses a user input string expected to be a timecode, given a CLI context. - - Returns: - (FrameTimecode) Timecode set to value with the CliContext VideoManager framerate. - If value is None, skips processing and returns None. - - Raises: - click.BadParameter - """ - cli_ctx.check_input_open() - if value is None: - return value - try: - timecode = FrameTimecode( - timecode=value, fps=cli_ctx.video_manager.get_framerate()) - return timecode - except (ValueError, TypeError): - raise click.BadParameter( - 'timecode must be in frames (1234), seconds (123.4s), or HH:MM:SS (00:02:03.400)') - - -def print_command_help(ctx, command): - # type: (click.Context, Callable[]) -> None - """ Print Command Help: Prints PySceneDetect help/usage for a given command. """ - ctx_name = ctx.info_name - ctx.info_name = command.name - click.echo(click.style('PySceneDetect %s Command' % command.name, fg='cyan')) - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(command.get_help(ctx)) - click.echo('') - ctx.info_name = ctx_name - - -def print_command_list_header(): - # type: () -> None - """ Print Command List Header: Prints header shown before the option/command list. """ - click.echo(click.style('PySceneDetect Option/Command List:', fg='green')) - click.echo(click.style('----------------------------------------------------', fg='green')) - click.echo('') - - -def print_help_header(): - # type: () -> None - """ Print Help Header: Prints header shown before the help command. """ - click.echo(click.style('----------------------------------------------------', fg='yellow')) - click.echo(click.style(' PySceneDetect %s Help' % scenedetect.__version__, fg='yellow')) - click.echo(click.style('----------------------------------------------------', fg='yellow')) - - -def duplicate_command(ctx, param_hint): - # type: (str) -> None - """ Duplicate Command: Called when a command is duplicated to stop parsing and raise an error. - - Called when a one-time use command is specified multiple times, displaying the appropriate - error and usage information. - - Raises: - click.BadParameter - """ - ctx.obj.options_processed = False - error_strs = [] - error_strs.append('Error: Command %s specified multiple times.' % param_hint) - error_strs.append('The %s command may appear only one time.') - - logging.error('\n'.join(error_strs)) - raise click.BadParameter('\n Command %s may only be specified once.' % param_hint, - param_hint='%s command' % param_hint) - - - -@click.group( - chain=True, context_settings=CLICK_CONTEXT_SETTINGS) -@click.option( - '--input', '-i', - multiple=True, required=False, metavar='VIDEO', - type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=True), help= - '[Required] Input video file.' - ' May be specified multiple times to concatenate several videos together.') -@click.option( - '--output', '-o', - multiple=False, required=False, metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), help= - 'Output directory for all files (stats file, output videos, images, log files, etc...).') -@click.option( - '--framerate', '-f', metavar='FPS', - type=click.FLOAT, default=None, help= - 'Force framerate, in frames/sec (e.g. -f 29.97). Disables check to ensure that all' - ' input videos have the same framerates.') -@click.option( - '--downscale', '-d', metavar='N', - type=click.INT, default=None, help= - 'Integer factor to downscale frames by (e.g. 2, 3, 4...), where the frame is scaled' - ' to width/N x height/N (thus -d 1 implies no downscaling). Each increment speeds up processing' - ' by a factor of 4 (e.g. -d 2 is 4 times quicker than -d 1). Higher values can be used for' - ' high definition content with minimal effect on accuracy.' - ' [default: 2 for SD, 4 for 720p, 6 for 1080p, 12 for 4k]') -@click.option( - '--frame-skip', '-fs', metavar='N', show_default=True, - type=click.INT, default=0, help= - 'Skips N frames during processing (-fs 1 skips every other frame, processing 50% of the video,' - ' -fs 2 processes 33% of the frames, -fs 3 processes 25%, etc...).' - ' Reduces processing speed at expense of accuracy.') -@click.option( - '--stats', '-s', metavar='CSV', - type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), help= - 'Path to stats file (.csv) for writing frame metrics to. If the file exists, any' - ' metrics will be processed, otherwise a new file will be created. Can be used to determine' - ' optimal values for various scene detector options, and to cache frame calculations in order' - ' to speed up multiple detection runs.') -@click.option( - '--verbosity', '-v', metavar='LEVEL', - type=click.Choice(['none', 'debug', 'info', 'warning', 'error']), default='info', help= - 'Level of debug/info/error information to show. Setting to none will' - ' suppress all output except that generated by actions (e.g. timecode list output).' - ' Can be overriden by `-q`/`--quiet`.') -@click.option( - '--logfile', '-l', metavar='LOG', - type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), help= - 'Path to log file for writing application logging information, mainly for debugging.' - ' Make sure to set `-v debug` as well if you are submitting a bug report.') -@click.option( - '--quiet', '-q', - is_flag=True, flag_value=True, help= - 'Suppresses all output of PySceneDetect except for those from the specified' - ' commands. Equivalent to setting `--verbosity none`. Overrides the current verbosity' - ' level, even if `-v`/`--verbosity` is set.') -@click.pass_context -def scenedetect_cli(ctx, input, output, framerate, downscale, frame_skip, stats, - verbosity, logfile, quiet): - """ For example: - - scenedetect -i video.mp4 -s video.stats.csv detect-content list-scenes - - Note that the following options represent [OPTIONS] above. To list the optional - [ARGS] for a particular COMMAND, type `scenedetect help COMMAND`. You can also - combine commands (e.g. scenedetect [...] detect-content save-images --png split-video). - - - """ - ctx.call_on_close(ctx.obj.process_input) - - logging.disable(logging.NOTSET) - - format_str = '[PySceneDetect] %(message)s' - if verbosity.lower() == 'none': - verbosity = None - elif verbosity.lower() == 'debug': - format_str = '%(levelname)s: %(module)s.%(funcName)s(): %(message)s' - - if quiet: - verbosity = None - - ctx.obj.output_directory = output - if logfile is not None: - logfile = ctx.obj.get_output_file_path(logfile) - logging.basicConfig( - filename=logfile, filemode='a', format=format_str, - level=getattr(logging, verbosity.upper()) if verbosity is not None else verbosity) - logging.info('Version: %s', scenedetect.__version__) - logging.info('Info Level: %s', verbosity) - else: - if verbosity is not None: - logging.basicConfig(format=format_str, - level=getattr(logging, verbosity.upper())) - else: - logging.disable(logging.CRITICAL) - - ctx.obj.quiet_mode = True if verbosity is None else False - - if stats is not None and frame_skip != 0: - ctx.obj.options_processed = False - error_strs = [ - 'Unable to detect scenes with stats file if frame skip is not 1.', - ' Either remove the -fs/--frame-skip option, or the -s/--stats file.\n'] - logging.error('\n'.join(error_strs)) - raise click.BadParameter( - '\n Combining the -s/--stats and -fs/--frame-skip options is not supported.', - param_hint='frame skip + stats file') - try: - if ctx.obj.output_directory is not None: - logging.info('Output directory set:\n %s', ctx.obj.output_directory) - ctx.obj.parse_options( - input_list=input, framerate=framerate, stats_file=stats, downscale=downscale, - frame_skip=frame_skip) - except: - logging.error('Could not parse CLI options.') - raise - - - -@click.command('help', add_help_option=False) -@click.argument('command_name', required=False, type=click.STRING) -@click.pass_context -def help_command(ctx, command_name): - """ Print help for command (help [command]). - """ - ctx.obj.options_processed = False - if command_name is not None: - if command_name.lower() == 'all': - print_help_header() - click.echo(get_help_command_preface(ctx.parent.info_name)) - print_command_list_header() - click.echo(ctx.parent.get_help()) - click.echo('') - for command in COMMAND_DICT: - print_command_help(ctx, command) - else: - command = None - for command_ref in COMMAND_DICT: - if command_name == command_ref.name: - command = command_ref - break - if command is None: - error_strs = [ - 'unknown command.', 'List of valid commands:', - ' %s' % ', '.join([command.name for command in COMMAND_DICT])] - raise click.BadParameter('\n'.join(error_strs), param_hint='command name') - click.echo('') - print_command_help(ctx, command) - else: - print_help_header() - click.echo(get_help_command_preface(ctx.parent.info_name)) - print_command_list_header() - click.echo(ctx.parent.get_help()) - click.echo( - "\nType '%s help [command]' for usage/help of [command], or" % ctx.parent.info_name) - click.echo( - "'%s help all' to list usage information for every command." % (ctx.parent.info_name)) - ctx.exit() - - - -@click.command('about', add_help_option=False) -@click.pass_context -def about_command(ctx): - """ Print license/copyright info. """ - ctx.obj.process_input_flag = False - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(click.style(' About PySceneDetect %s' % scenedetect.__version__, fg='yellow')) - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(scenedetect.ABOUT_STRING) - ctx.exit() - - - -@click.command('version', add_help_option=False) -@click.pass_context -def version_command(ctx): - """ Print version of PySceneDetect. """ - ctx.obj.process_input_flag = False - click.echo(click.style('PySceneDetect %s' % scenedetect.__version__, fg='yellow')) - ctx.exit() - - - -@click.command('time') -@click.option( - '--start', '-s', metavar='TIMECODE', - type=click.STRING, default='0', show_default=True, help= - 'Time in video to begin detecting scenes. TIMECODE can be specified as exact' - ' number of frames (-s 100 to start at frame 100), time in seconds followed by s' - ' (-s 100s to start at 100 seconds), or a timecode in the format HH:MM:SS or HH:MM:SS.nnn' - ' (-s 00:01:40 to start at 1m40s).') -@click.option( - '--duration', '-d', metavar='TIMECODE', - type=click.STRING, default=None, help= - 'Maximum time in video to process. TIMECODE format is the same as other' - ' arguments. Mutually exclusive with --end / -e.') -@click.option( - '--end', '-e', metavar='TIMECODE', - type=click.STRING, default=None, help= - 'Time in video to end detecting scenes. TIMECODE format is the same as other' - ' arguments. Mutually exclusive with --duration / -d.') -@click.pass_context -def time_command(ctx, start, duration, end): - """ Set start/end/duration of input video(s). - - Time values can be specified as frames (NNNN), seconds (NNNN.NNs), or as - a timecode (HH:MM:SS.nnn). For example, to start scene detection at 1 minute, - and stop after 100 seconds: - - time --start 00:01:00 --duration 100s - - 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: - - time --start 0 --end 1000 - """ - start = parse_timecode(ctx.obj, start) - duration = parse_timecode(ctx.obj, duration) - end = parse_timecode(ctx.obj, end) - - ctx.obj.time_command(start, duration, end) - - - -@click.command('detect-content') -@click.option( - '--threshold', '-t', metavar='VAL', - type=click.FLOAT, default=30.0, show_default=True, help= - 'Threshold value (float) that the delta_hsv frame metric must exceed to trigger a new scene.' - ' Refers to frame metric delta_hsv_avg in stats file.') -#@click.option( -# '--intensity-cutoff', '-i', metavar='VAL', -# type=click.FLOAT, default=None, show_default=True, help= -# '[Optional] Intensity cutoff threshold to disable scene cut detection. Useful for avoiding.' -# ' scene changes triggered by flashes. Refers to frame metric delta_lum in stats file.') -@click.option( - '--min-scene-len', '-m', metavar='FRAMES', - type=click.INT, default=15, show_default=True, help= - 'Minimum size/length of any scene, in number of frames.') -@click.pass_context -def detect_content_command(ctx, threshold, min_scene_len): #, intensity_cutoff): - """ Perform content detection algorithm on input video(s). - - detect-content - - detect-content --threshold 27.5 - """ - - #if intensity_cutoff is not None: - # raise NotImplementedError() - - logging.debug('Detecting content, parameters:\n' - ' threshold: %d, min-scene-len: %d', - threshold, min_scene_len) - - # Initialize detector and add to scene manager. - # Need to ensure that a detector is not added twice, or will cause - # a frame metric key error when registering the detector. - ctx.obj.add_detector(scenedetect.detectors.ContentDetector( - threshold=threshold, min_scene_len=min_scene_len)) - - - -@click.command('detect-threshold') -@click.option( - '--threshold', '-t', metavar='VAL', - type=click.IntRange(0, 255), default=12, show_default=True, help= - 'Threshold value (integer) that the delta_rgb frame metric must exceed to trigger a new scene.' - ' Refers to frame metric delta_rgb in stats file.') -@click.option( - '--min-scene-len', '-m', metavar='FRAMES', - type=click.INT, default=15, show_default=True, help= - 'Minimum size/length of any scene, in number of frames.') -@click.option( - '--fade-bias', '-f', metavar='PERCENT', - type=click.IntRange(-100, 100), default=0, show_default=True, help= - 'Percent (%) from -100 to 100 of timecode skew for where cuts should be placed. -100' - ' indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.') -@click.option( - '--add-last-scene', '-l', - is_flag=True, flag_value=True, help= - 'If set, if the video ends on a fade-out, an additional scene will be generated for the' - ' last fade out position.') -@click.option( - '--min-percent', '-p', metavar='PERCENT', - type=click.IntRange(0, 100), default=95, show_default=True, help= - 'Percent (%) from 0 to 100 of amount of pixels that must meet the threshold value in order' - 'to trigger a scene change.') -@click.option( - '--block-size', '-b', metavar='N', - type=click.IntRange(1, 128), default=8, show_default=True, help= - 'Number of rows in image to sum per iteration (can be tuned for performance in some cases).') -@click.pass_context -def detect_threshold_command(ctx, threshold, min_scene_len, fade_bias, add_last_scene, - min_percent, block_size): - """ Perform threshold detection algorithm on input video(s). - - detect-threshold - - detect-threshold --threshold 15 - """ - - logging.debug('Detecting threshold, parameters:\n' - ' threshold: %d, min-scene-len: %d, fade-bias: %d,\n' - ' add-last-scene: %s, min-percent: %d, block-size: %d', - threshold, min_scene_len, fade_bias, - 'yes' if add_last_scene else 'no', min_percent, block_size) - - # Handle case where add_last_scene is not set and is None. - add_last_scene = True if add_last_scene else False - - # Convert min_percent and fade_bias from integer to floats (0.0-1.0 and -1.0-+1.0 respectively). - min_percent /= 100.0 - fade_bias /= 100.0 - ctx.obj.add_detector(scenedetect.detectors.ThresholdDetector( - threshold=threshold, min_scene_len=min_scene_len, fade_bias=fade_bias, - add_final_scene=add_last_scene, min_percent=min_percent, block_size=block_size)) - -@click.command('list-scenes', add_help_option=False) -@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.') -@click.option( - '--filename', '-f', metavar='NAME', default='$VIDEO_NAME-Scenes.csv', - type=click.STRING, show_default=True, help= - 'Filename format to use for the scene list CSV file. You can use the' - ' $VIDEO_NAME macro in the file name.') -@click.option( - '--no-output-file', '-n', - is_flag=True, flag_value=True, help= - 'Disable writing scene list CSV file to disk. If set, -o/--output and' - ' -f/--filename are ignored.') -@click.option( - '--quiet', '-q', - is_flag=True, flag_value=True, help= - 'Suppresses output of the table printed by the list-scenes command.') -@click.pass_context -def list_scenes_command(ctx, output, filename, no_output_file, quiet): - """ Prints scene list and outputs to a CSV file. The default filename is - $VIDEO_NAME-Scenes.csv. """ - if ctx.obj.list_scenes: - duplicate_command(ctx, 'list-scenes') - ctx.obj.list_scenes_command(output, filename, no_output_file, quiet) - ctx.obj.list_scenes = True - - - -@click.command('split-video', add_help_option=False) -@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.') -@click.option( - '--filename', '-f', metavar='NAME', default='$VIDEO_NAME-Scene-$SCENE_NUMBER', - type=click.STRING, show_default=True, help= - 'File name format, to use when saving image files. You can use the' - ' $VIDEO_NAME and $SCENE_NUMBER macros in the file name.') -@click.option( - '--high-quality', '-hq', - is_flag=True, flag_value=True, help= - 'Encode video with higher quality, overrides -f option if present.' - ' Equivalent to specifying --rate-factor 17 and --preset slow.') -@click.option( - '--override-args', '-a', metavar='ARGS', - type=click.STRING, help= - 'Override codec arguments/options passed to FFmpeg when splitting and re-encoding' - ' scenes. Use double quotes (") around specified arguments. Must specify at least' - ' audio/video codec to use (e.g. -a "-c:v [...] and -c:a [...]"). [default:' - ' "-c:v libx264 -preset veryfast -crf 22 -c:a copy"]') -@click.option( - '--quiet', '-q', - is_flag=True, flag_value=True, help= - 'Suppresses output from external video splitting tool.') -@click.option( - '--copy', '-c', - is_flag=True, flag_value=True, help= - 'Copy instead of re-encode using mkvmerge instead of ffmpeg for splitting videos.' - ' All other arguments except -o/--output and -q/--quiet are ignored in this mode,' - ' and output files will be named $VIDEO_NAME-$SCENE_NUMBER.mkv.' - ' Significantly faster when splitting videos, however,' - ' output videos sometimes may not be split exactly, especially if the scenes' - ' are very short in length, or the input video is heavily compressed. This can' - ' lead to smaller scenes being merged with others, or scene boundaries being' - ' shifted in time - thus when using this option, the number of videos written' - ' may not match the number of scenes that was detected.') -@click.option( - '--rate-factor', '-crf', metavar='RATE', default=None, - type=click.IntRange(0, 100), help= - 'Video encoding quality (x264 constant rate factor), from 0-100, where lower' - ' values represent better quality, with 0 indicating lossless.' - ' [default: 22, if -hq/--high-quality is set: 17]') -@click.option( - '--preset', '-p', metavar='LEVEL', default=None, - type=click.Choice([ - 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', - 'slow', 'slower', 'veryslow']), - help= - 'Video compression quality preset (x264 preset). Can be one of: ultrafast, superfast,' - ' veryfast, faster, fast, medium, slow, slower, and veryslow. Faster modes take less' - ' time to run, but the output files may be larger.' - ' [default: veryfast, if -hq/--high quality is set: slow]') -@click.pass_context -def split_video_command(ctx, output, filename, high_quality, override_args, quiet, copy, - rate_factor, preset): - """Split input video(s) using ffmpeg or mkvmerge.""" - if ctx.obj.split_video: - logging.warning('split-video command is specified twice.') - ctx.obj.check_input_open() - ctx.obj.split_video = True - ctx.obj.split_quiet = True if quiet else False - ctx.obj.split_directory = output - ctx.obj.split_name_format = filename - if copy: - ctx.obj.split_mkvmerge = True - if high_quality: - logging.warning('-hq/--high-quality flag ignored due to -c/--copy.') - if override_args: - logging.warning('-f/--ffmpeg-args option ignored due to -c/--copy.') - if not override_args: - if rate_factor is None: - rate_factor = 22 if not high_quality else 17 - if preset is None: - preset = 'veryfast' if not high_quality else 'slow' - override_args = ('-c:v libx264 -preset {PRESET} -crf {RATE_FACTOR} -c:a copy'.format( - PRESET=preset, RATE_FACTOR=rate_factor)) - if not copy: - logging.info('FFmpeg codec args set: %s', override_args) - if filename: - logging.info('Video output file name format: %s', filename) - if ctx.obj.split_directory is not None: - logging.info('Video output path set: \n%s', ctx.obj.split_directory) - ctx.obj.split_args = override_args - - mkvmerge_available = is_mkvmerge_available() - ffmpeg_available = is_ffmpeg_available() - if not (mkvmerge_available or ffmpeg_available) or ( - (not mkvmerge_available and copy) or (not ffmpeg_available and not copy)): - split_tool = 'ffmpeg/mkvmerge' - if (not mkvmerge_available and copy): - split_tool = 'mkvmerge' - elif (not ffmpeg_available and not copy): - split_tool = 'ffmpeg' - error_strs = [ - "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format( - EXTERN_TOOL=split_tool, EXTRA_ARGS=' -c/--copy' if copy else ''), - "Install the above tool%s to enable video splitting support." % ( - 's' if split_tool.find('/') > 0 else '')] - if mkvmerge_available: - error_strs += [ - 'You can also specify `split-video -c/--copy` to use mkvmerge for splitting.'] - error_str = '\n'.join(error_strs) - logging.debug(error_str) - ctx.obj.options_processed = False - raise click.BadParameter(error_str, param_hint='split-video') - - - -@click.command('save-images', add_help_option=False) -@click.option( - '--output', '-o', metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), help= - 'Output directory to save images to. Overrides global option -o/--output if set.') -@click.option( - '--filename', '-f', metavar='NAME', default='$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', - type=click.STRING, show_default=True, help= - 'Filename format, *without* extension, to use when saving image files. You can use the' - ' $VIDEO_NAME, $SCENE_NUMBER, and $IMAGE_NUMBER macros in the file name.') -@click.option( - '--num-images', '-n', metavar='N', default=3, - type=click.INT, help= - 'Number of images to generate. Will always include start/end frame,' - ' unless N = 1, in which case the image will be the frame at the mid-point' - ' in the scene.') -@click.option( - '--jpeg', '-j', - is_flag=True, flag_value=True, help= - 'Set output format to JPEG. [default]') -@click.option( - '--webp', '-w', - is_flag=True, flag_value=True, help= - 'Set output format to WebP.') -@click.option( - '--quality', '-q', 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]') -@click.option( - '--png', '-p', - is_flag=True, flag_value=True, help= - 'Set output format to PNG.') -@click.option( - '--compression', '-c', 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. [default: 3]') -@click.pass_context -def save_images_command(ctx, output, filename, num_images, jpeg, webp, quality, png, compression): - """ Create images for each detected scene. """ - if ctx.obj.save_images: - duplicate_command(ctx, 'save-images') - ctx.obj.save_images_command(num_images, output, filename, jpeg, webp, quality, png, compression) - - - -@click.command('colors', add_help_option=False) -@click.option( - '--colors', '-c', metavar='N', - type=click.INT, default=4, help= - 'Number of color averages to generate.') -@click.option( - '--generate-pallette', '-p', metavar='N', - type=click.INT, default=4, help= - 'Flag which, if set, saves an image with the colors in a grid as for use as a pallette.') -@click.pass_context -def colors_command(ctx): - """ Colors Command: Generates pallette/image of average N colours in video, and each scene. - - Not implemented yet, needs to be added to backlog. - """ - raise NotImplementedError() - - - -# Info/Terminating Commands: -add_cli_command(scenedetect_cli, help_command) -add_cli_command(scenedetect_cli, about_command) -add_cli_command(scenedetect_cli, version_command) - -# Commands Added To Help List: -add_cli_command(scenedetect_cli, time_command) -add_cli_command(scenedetect_cli, detect_content_command) -add_cli_command(scenedetect_cli, detect_threshold_command) -add_cli_command(scenedetect_cli, list_scenes_command) - -add_cli_command(scenedetect_cli, save_images_command) -add_cli_command(scenedetect_cli, split_video_command) - diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py deleted file mode 100644 index a0e8fb51..00000000 --- a/scenedetect/cli/context.py +++ /dev/null @@ -1,672 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect scenedetect.cli.context Module - -This file contains the implementation of the PySceneDetect command-line -interface (CLI) context class CliContext, used for the main application -state/context and logic to run the PySceneDetect CLI. -""" - -# Standard Library Imports -from __future__ import print_function -import logging -import os -import time -import math -from string import Template - -# Third-Party Library Imports -import click -import cv2 -from scenedetect.platform import tqdm - -# PySceneDetect Library Imports -import scenedetect.detectors - -from scenedetect.scene_manager import SceneManager -from scenedetect.scene_manager import write_scene_list - -from scenedetect.stats_manager import StatsManager -from scenedetect.stats_manager import StatsFileCorrupt -from scenedetect.stats_manager import StatsFileFramerateMismatch - -from scenedetect.video_manager import VideoManager -from scenedetect.video_manager import VideoOpenFailure -from scenedetect.video_manager import VideoFramerateUnavailable -from scenedetect.video_manager import VideoParameterMismatch -from scenedetect.video_manager import InvalidDownscaleFactor - -from scenedetect.video_splitter import is_mkvmerge_available -from scenedetect.video_splitter import is_ffmpeg_available -from scenedetect.video_splitter import split_video_mkvmerge -from scenedetect.video_splitter import split_video_ffmpeg - -from scenedetect.platform import get_cv2_imwrite_params -from scenedetect.platform import check_opencv_ffmpeg_dll - - -def get_plural(val_list): - """ Get Plural: Helper function to return 's' if a list has more than one (1) - element, otherwise returns ''. - - Returns: - str: String of 's' if the length of val_list is greater than 1, otherwise ''. - """ - return 's' if len(val_list) > 1 else '' - - -class CliContext(object): - """ Context of the command-line interface passed between the various sub-commands. - - Pools all options, processing the main program options as they come in (e.g. those - not passed to a command), followed by parsing each sub-command's options, preparing - the actions to be executed in the process_input() method, which is called after the - whole command line has been processed (successfully nor not). - - This class and the cli.__init__ module make up the bulk of the PySceneDetect - application logic for the command line. - """ - - def __init__(self): - # Properties for main scenedetect command options (-i, -s, etc...) and CliContext logic. - self.options_processed = False # True when CLI option parsing is complete. - self.scene_manager = None # detect-content, detect-threshold, etc... - self.video_manager = None # -i/--input, -d/--downscale - self.base_timecode = None # -f/--framerate - self.start_frame = 0 # time -s/--start - self.stats_manager = None # -s/--stats - self.stats_file_path = None # -s/--stats - self.output_directory = None # -o/--output - self.quiet_mode = False # -q/--quiet or -v/--verbosity quiet - self.frame_skip = 0 # -fs/--frame-skip - # Properties for save-images command. - self.save_images = False # save-images command - self.image_extension = 'jpg' # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_directory = None # save-images -o/--output - - self.image_param = None # save-images -q/--quality if -j/-w, - # -c/--compression if -p - - - self.image_name_format = ( # save-images -f/--name-format - '$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER') - self.num_images = 2 # save-images -n/--num-images - self.imwrite_params = get_cv2_imwrite_params() - # Properties for split-video command. - self.split_video = False # split-video command - self.split_mkvmerge = False # split-video -c/--copy - self.split_args = None # split-video -a/--override-args - self.split_directory = None # split-video -o/--output - self.split_name_format = '$VIDEO_NAME-Scene-$SCENE_NUMBER' # split-video -f/--filename - self.split_quiet = False # split-video -q/--quiet - # Properties for list-scenes command. - self.list_scenes = False # list-scenes command - self.print_scene_list = False # list-scenes --quiet/-q - self.scene_list_directory = None # list-scenes -o/--output - self.scene_list_name_format = None # list-scenes -f/--filename - self.scene_list_output = False # list-scenes -n/--no-output - - - def cleanup(self): - # type: () -> None - """ Cleanup: Releases all resources acquired by the CliContext (esp. the VideoManager). """ - try: - logging.debug('Cleaning up...\n\n') - finally: - if self.video_manager is not None: - self.video_manager.release() - - - def _generate_images(self, scene_list, video_name, - image_name_template='$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', - output_dir=None): - # type: (List[Tuple[FrameTimecode, FrameTimecode]) -> None - - if not scene_list: - return - if not self.options_processed: - return - if self.num_images <= 0: - raise ValueError() - self.check_input_open() - - imwrite_param = [] - if self.image_param is not None: - imwrite_param = [self.imwrite_params[self.image_extension], self.image_param] - - # Reset video manager and downscale factor. - self.video_manager.release() - self.video_manager.reset() - self.video_manager.set_downscale_factor(1) - self.video_manager.start() - - # Setup flags and init progress bar if available. - completed = True - logging.info('Generating output images (%d per scene)...', self.num_images) - progress_bar = None - if tqdm and not self.quiet_mode: - progress_bar = tqdm( - total=len(scene_list) * self.num_images, unit='images') - - 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(self.num_images, 10)) + 2) + 'd' - - timecode_list = dict() - - for i in range(len(scene_list)): - timecode_list[i] = [] - - if self.num_images == 1: - for i, (start_time, end_time) in enumerate(scene_list): - duration = end_time - start_time - timecode_list[i].append(start_time + int(duration.get_frames() / 2)) - - else: - middle_images = self.num_images - 2 - for i, (start_time, end_time) in enumerate(scene_list): - timecode_list[i].append(start_time) - - if middle_images > 0: - duration = (end_time.get_frames() - 1) - start_time.get_frames() - duration_increment = None - duration_increment = int(duration / (middle_images + 1)) - for j in range(middle_images): - timecode_list[i].append(start_time + ((j+1) * duration_increment)) - - # End FrameTimecode is always the same frame as the next scene's start_time - # (one frame past the end), so we need to subtract 1 here. - timecode_list[i].append(end_time - 1) - - for i in timecode_list: - for j, image_timecode in enumerate(timecode_list[i]): - self.video_manager.seek(image_timecode) - self.video_manager.grab() - ret_val, frame_im = self.video_manager.retrieve() - if ret_val: - cv2.imwrite( - self.get_output_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) - ), self.image_extension), - output_dir=output_dir), frame_im, imwrite_param) - else: - completed = False - break - if progress_bar: - progress_bar.update(1) - - if not completed: - logging.error('Could not generate all output images.') - - - def get_output_file_path(self, file_path, output_dir=None): - # type: (str, Optional[str]) -> str - """ Get Output File Path: Gets full path to output file passed as argument, in - the specified global output directory (scenedetect -o/--output) if set, creating - any required directories along the way. - - Arguments: - file_path (str): File name to get path for. If file_path is an absolute - path (e.g. starts at a drive/root), no modification of the path - is performed, only ensuring that all output directories are created. - output_dir (Optional[str]): An optional output directory to override the - global output directory option, if set. - - Returns: - (str) Full path to output file suitable for writing. - - """ - if file_path is None: - return None - output_dir = self.output_directory if output_dir is None else output_dir - # 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_dir is not None and not os.path.isabs(file_path): - file_path = os.path.join(output_dir, 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. - try: - os.makedirs(os.path.split(os.path.abspath(file_path))[0]) - except OSError: - pass - return file_path - - def _open_stats_file(self): - - if self.stats_manager is None: - self.stats_manager = StatsManager() - - if self.stats_file_path is not None: - if os.path.exists(self.stats_file_path): - logging.info('Loading frame metrics from stats file: %s', - os.path.basename(self.stats_file_path)) - try: - with open(self.stats_file_path, 'rt') as stats_file: - self.stats_manager.load_from_csv(stats_file, self.base_timecode) - except StatsFileCorrupt: - error_strs = [ - 'Could not load stats file.', 'Failed to parse stats file:', - 'Could not load frame metrics from stats file - file is corrupt or not a' - ' valid PySceneDetect stats file. If the file exists, ensure that it is' - ' a valid stats file CSV, otherwise delete it and run PySceneDetect again' - ' to re-generate the stats file.'] - logging.error('\n'.join(error_strs)) - raise click.BadParameter( - '\n Could not load given stats file, see above output for details.', - param_hint='input stats file') - except StatsFileFramerateMismatch as ex: - error_strs = [ - 'could not load stats file.', 'Failed to parse stats file:', - 'Framerate differs between stats file (%.2f FPS) and input' - ' video%s (%.2f FPS)' % ( - ex.stats_file_fps, - 's' if self.video_manager.get_num_videos() > 1 else '', - ex.base_timecode_fps), - 'Ensure the correct stats file path was given, or delete and re-generate' - ' the stats file.'] - logging.error('\n'.join(error_strs)) - raise click.BadParameter( - 'framerate differs between given stats file and input video(s).', - param_hint='input stats file') - - - def process_input(self): - # type: () -> None - """ Process Input: Processes input video(s) and generates output as per CLI commands. - - Run after all command line options/sub-commands have been parsed. - """ - logging.debug('Processing input...') - if not self.options_processed: - logging.debug('Skipping processing, CLI options were not parsed successfully.') - return - self.check_input_open() - if not self.scene_manager.get_num_detectors() > 0: - logging.error( - 'No scene detectors specified (detect-content, detect-threshold, etc...),\n' - ' or failed to process all command line arguments.') - return - - # Handle scene detection commands (detect-content, detect-threshold, etc...). - self.video_manager.start() - base_timecode = self.video_manager.get_base_timecode() - - start_time = time.time() - logging.info('Detecting scenes...') - - num_frames = self.scene_manager.detect_scenes( - frame_source=self.video_manager, frame_skip=self.frame_skip, - show_progress=not self.quiet_mode) - - duration = time.time() - start_time - logging.info('Processed %d frames in %.1f seconds (average %.2f FPS).', - num_frames, duration, float(num_frames)/duration) - - # Handle -s/--statsfile option. - if self.stats_file_path is not None: - if self.stats_manager.is_save_required(): - with open(self.stats_file_path, 'wt') as stats_file: - logging.info('Saving frame metrics to stats file: %s', - os.path.basename(self.stats_file_path)) - self.stats_manager.save_to_csv( - stats_file, base_timecode) - else: - logging.debug('No frame metrics updated, skipping update of the stats file.') - - # Get list of detected cuts and scenes from the SceneManager to generate the required output - # files with based on the given commands (list-scenes, split-video, save-images, etc...). - cut_list = self.scene_manager.get_cut_list(base_timecode) - scene_list = self.scene_manager.get_scene_list(base_timecode) - video_paths = self.video_manager.get_video_paths() - video_name = os.path.basename(video_paths[0]) - if video_name.rfind('.') >= 0: - video_name = video_name[:video_name.rfind('.')] - - # Ensure we don't divide by zero. - if scene_list: - logging.info('Detected %d scenes, average shot length %.1f seconds.', - len(scene_list), - sum([(end_time - start_time).get_seconds() - for start_time, end_time in scene_list]) / float(len(scene_list))) - else: - logging.info('No scenes detected.') - - # Handle list-scenes command. - if self.scene_list_output: - scene_list_filename = Template(self.scene_list_name_format).safe_substitute( - VIDEO_NAME=video_name) - if not scene_list_filename.lower().endswith('.csv'): - scene_list_filename += '.csv' - scene_list_path = self.get_output_file_path( - scene_list_filename, self.scene_list_directory) - logging.info('Writing scene list to CSV file:\n %s', scene_list_path) - with open(scene_list_path, 'wt') as scene_list_file: - write_scene_list(scene_list_file, scene_list, cut_list) - # Handle `list-scenes`. - if self.print_scene_list: - logging.info("""Scene List: ------------------------------------------------------------------------ - | Scene # | Start Frame | Start Time | End Frame | End Time | ------------------------------------------------------------------------ -%s ------------------------------------------------------------------------ -""", '\n'.join( - [' | %5d | %11d | %s | %11d | %s |' % ( - i+1, - start_time.get_frames(), start_time.get_timecode(), - end_time.get_frames(), end_time.get_timecode()) - for i, (start_time, end_time) in enumerate(scene_list)])) - - - if cut_list: - logging.info('Comma-separated timecode list:\n %s', - ','.join([cut.get_timecode() for cut in cut_list])) - - # Handle save-images command. - if self.save_images: - self._generate_images(scene_list=scene_list, video_name=video_name, - image_name_template=self.image_name_format, - output_dir=self.image_directory) - - # Handle split-video command. - if self.split_video: - # Add proper extension to filename template if required. - dot_pos = self.split_name_format.rfind('.') - if self.split_mkvmerge and not self.split_name_format.endswith('.mkv'): - self.split_name_format += '.mkv' - # Don't add if we find an extension between 2 and 4 characters - elif not (dot_pos >= 0) or ( - dot_pos >= 0 and not - ((len(self.split_name_format) - (dot_pos+1) <= 4 >= 2))): - self.split_name_format += '.mp4' - - output_file_prefix = self.get_output_file_path( - self.split_name_format, output_dir=self.split_directory) - mkvmerge_available = is_mkvmerge_available() - ffmpeg_available = is_ffmpeg_available() - if mkvmerge_available and (self.split_mkvmerge or not ffmpeg_available): - if not self.split_mkvmerge: - logging.warning( - 'ffmpeg not found, falling back to fast copy mode (split-video -c/--copy).') - split_video_mkvmerge(video_paths, scene_list, output_file_prefix, video_name, - suppress_output=self.quiet_mode or self.split_quiet) - elif ffmpeg_available: - if self.split_mkvmerge: - logging.warning('mkvmerge not found, falling back to normal splitting' - ' mode (split-video).') - split_video_ffmpeg(video_paths, scene_list, output_file_prefix, - video_name, arg_override=self.split_args, - hide_progress=self.quiet_mode, - suppress_output=self.quiet_mode or self.split_quiet) - else: - if not (mkvmerge_available or ffmpeg_available): - error_strs = ["ffmpeg/mkvmerge is required for split-video [-c/--copy]."] - else: - error_strs = [ - "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format( - EXTERN_TOOL='mkvmerge' if self.split_mkvmerge else 'ffmpeg', - EXTRA_ARGS=' -c/--copy' if self.split_mkvmerge else '')] - error_strs += ["Install one of the above tools to enable the split-video command."] - error_str = '\n'.join(error_strs) - logging.debug(error_str) - raise click.BadParameter(error_str, param_hint='split-video') - if scene_list: - logging.info('Video splitting completed, individual scenes written to disk.') - - - - def check_input_open(self): - # type: () -> None - """ Check Input Open: Ensures that the CliContext's VideoManager was initialized, - started, and at *least* one input video was successfully opened - otherwise, an - exception is raised. - - Raises: - click.BadParameter - """ - if self.video_manager is None or not self.video_manager.get_num_videos() > 0: - error_strs = ["No input video(s) specified.", - "Make sure '--input VIDEO' is specified at the start of the command."] - error_str = '\n'.join(error_strs) - logging.debug(error_str) - raise click.BadParameter(error_str, param_hint='input video') - - - def add_detector(self, detector): - """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """ - self.check_input_open() - options_processed_orig = self.options_processed - self.options_processed = False - try: - self.scene_manager.add_detector(detector) - except scenedetect.stats_manager.FrameMetricRegistered: - raise click.BadParameter(message='Cannot specify detection algorithm twice.', - param_hint=detector.cli_name) - self.options_processed = options_processed_orig - - - def _init_video_manager(self, input_list, framerate, downscale): - - self.base_timecode = None - - logging.debug('Initializing VideoManager.') - video_manager_initialized = False - try: - self.video_manager = VideoManager( - video_files=input_list, framerate=framerate, logger=logging) - video_manager_initialized = True - self.base_timecode = self.video_manager.get_base_timecode() - self.video_manager.set_downscale_factor(downscale) - except VideoOpenFailure as ex: - error_strs = [ - 'could not open video%s.' % get_plural(ex.file_list), - 'Failed to open the following video file%s:' % get_plural(ex.file_list)] - error_strs += [' %s' % file_name[0] for file_name in ex.file_list] - dll_okay, dll_name = check_opencv_ffmpeg_dll() - if not dll_okay: - error_strs += [ - 'Error: OpenCV dependency %s not found.' % dll_name, - 'Ensure that you installed the Python OpenCV module, and that the', - '%s file can be found to enable video support.' % dll_name] - logging.debug('\n'.join(error_strs[1:])) - if not dll_okay: - click.echo(click.style( - '\nOpenCV dependency missing, video input/decoding not available.\n', fg='red')) - raise click.BadParameter('\n'.join(error_strs), param_hint='input video') - except VideoFramerateUnavailable as ex: - error_strs = ['could not get framerate from video(s)', - 'Failed to obtain framerate for video file %s.' % ex.file_name] - error_strs.append('Specify framerate manually with the -f / --framerate option.') - logging.debug('\n'.join(error_strs)) - raise click.BadParameter('\n'.join(error_strs), param_hint='input video') - except VideoParameterMismatch as ex: - error_strs = ['video parameters do not match.', 'List of mismatched parameters:'] - for param in ex.file_list: - if param[0] == cv2.CAP_PROP_FPS: - param_name = 'FPS' - if param[0] == cv2.CAP_PROP_FRAME_WIDTH: - param_name = 'Frame width' - if param[0] == cv2.CAP_PROP_FRAME_HEIGHT: - param_name = 'Frame height' - error_strs.append(' %s mismatch in video %s (got %.2f, expected %.2f)' % ( - param_name, param[3], param[1], param[2])) - error_strs.append( - 'Multiple videos may only be specified if they have the same framerate and' - ' resolution. -f / --framerate may be specified to override the framerate.') - logging.debug('\n'.join(error_strs)) - raise click.BadParameter('\n'.join(error_strs), param_hint='input videos') - except InvalidDownscaleFactor as ex: - error_strs = ['Downscale value is not > 0.', str(ex)] - logging.debug('\n'.join(error_strs)) - raise click.BadParameter('\n'.join(error_strs), param_hint='downscale factor') - return video_manager_initialized - - - def parse_options(self, input_list, framerate, stats_file, downscale, frame_skip): - # type: (List[str], float, str, int, int) -> None - """ Parse Options: Parses all global options/arguments passed to the main - scenedetect command, before other sub-commands (e.g. this function processes - the [options] when calling scenedetect [options] [commands [command options]]. - - This method calls the _init_video_manager(), _open_stats_file(), and - check_input_open() methods, which may raise a click.BadParameter exception. - - Raises: - click.BadParameter - """ - if not input_list: - return - - logging.debug('Parsing program options.') - - self.frame_skip = frame_skip - - video_manager_initialized = self._init_video_manager( - input_list=input_list, framerate=framerate, downscale=downscale) - - # Ensure VideoManager is initialized, and open StatsManager if --stats is specified. - if not video_manager_initialized: - self.video_manager = None - logging.info('VideoManager not initialized.') - else: - logging.debug('VideoManager initialized.') - self.stats_file_path = self.get_output_file_path(stats_file) - if self.stats_file_path is not None: - self.check_input_open() - self._open_stats_file() - - # Init SceneManager. - self.scene_manager = SceneManager(self.stats_manager) - - self.options_processed = True - - - def time_command(self, start=None, duration=None, end=None): - # type: (Optional[str], Optional[str], Optional[str]) -> None - """ Time Command: Parses all options/arguments passed to the time command, - or with respect to the CLI, this function processes [time options] when calling: - scenedetect [global options] time [time options] [other commands...]. - - Raises: - click.BadParameter, VideoDecodingInProgress - """ - logging.debug('Setting video time:\n start: %s, duration: %s, end: %s', - start, duration, end) - - self.check_input_open() - - if duration is not None and end is not None: - raise click.BadParameter( - 'Only one of --duration/-d or --end/-e can be specified, not both.', - param_hint='time') - - self.video_manager.set_duration(start_time=start, duration=duration, end_time=end) - - if start is not None: - self.start_frame = start.get_frames() - - - def list_scenes_command(self, output_path, filename_format, no_output_mode, quiet_mode): - # type: (str, str, bool, bool) -> None - """ List Scenes Command: Parses all options/arguments passed to the list-scenes command, - or with respect to the CLI, this function processes [list-scenes options] when calling: - scenedetect [global options] list-scenes [list-scenes options] [other commands...]. - - Raises: - click.BadParameter - """ - self.check_input_open() - - self.print_scene_list = True if quiet_mode is None else not quiet_mode - self.scene_list_directory = output_path - self.scene_list_name_format = filename_format - if self.scene_list_name_format is not None and not no_output_mode: - logging.info('Scene list CSV file name format:\n %s', self.scene_list_name_format) - self.scene_list_output = False if no_output_mode else True - if self.scene_list_directory is not None: - logging.info('Scene list output directory set:\n %s', self.scene_list_directory) - - - def save_images_command(self, num_images, output, name_format, jpeg, webp, quality, - png, compression): - # type: (int, str, str, bool, bool, int, bool, int) -> None - """ Save Images Command: Parses all options/arguments passed to the save-images command, - or with respect to the CLI, this function processes [save-images options] when calling: - scenedetect [global options] save-images [save-images options] [other commands...]. - - Raises: - click.BadParameter - """ - self.check_input_open() - - num_flags = sum([True if flag else False for flag in [jpeg, webp, png]]) - if num_flags <= 1: - - # Ensure the format exists. - extension = 'jpg' # Default is jpg. - if png: - extension = 'png' - elif webp: - extension = 'webp' - if not extension in self.imwrite_params or self.imwrite_params[extension] is None: - error_strs = [ - 'Image encoder type %s not supported.' % extension.upper(), - '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. '] - logging.debug('\n'.join(error_strs)) - raise click.BadParameter('\n'.join(error_strs), param_hint='save-images') - - self.save_images = True - self.image_directory = output - self.image_extension = extension - self.image_param = compression if png else quality - self.image_name_format = name_format - self.num_images = num_images - - image_type = 'JPEG' if self.image_extension == 'jpg' else self.image_extension.upper() - image_param_type = '' - if self.image_param: - image_param_type = 'Compression' if image_type == 'PNG' else 'Quality' - image_param_type = ' [%s: %d]' % (image_param_type, self.image_param) - logging.info('Image output format set: %s%s', image_type, image_param_type) - if self.image_directory is not None: - logging.info('Image output directory set:\n %s', - os.path.abspath(self.image_directory)) - else: - self.options_processed = False - logging.error('Multiple image type flags set for save-images command.') - raise click.BadParameter( - 'Only one image type (JPG/PNG/WEBP) can be specified.', param_hint='save-images') - 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 c0c0e0de..16238025 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -1,67 +1,52 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. +# 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.detectors`` Module +This module contains the following scene detection algorithms: -""" PySceneDetect `scenedetect.detectors` Module + * :mod:`ContentDetector `: + Detects shot changes using weighted average of pixel changes in the HSV colorspace. -This module contains implementations of scene detection algorithms by inhereting -from the base SceneDetector class (in scenedetect.scene_detector) and implementing -the required methods. This allows implementation of other generic algorithms as -well as custom scenario-specific algorithms. + * :mod:`ThresholdDetector `: + Detects slow transitions using average pixel intensity in RGB (fade in/fade out) -Individual detectors are imported in this file for easy access from other -modules (i.e. from scenedetect.detectors import ContentDetector). -""" + * :mod:`AdaptiveDetector `: + Performs rolling average on differences in HSV colorspace. In some cases, this can improve + handling of fast motion. + + * :mod:`HistogramDetector `: + Uses histogram differences for Y channel in YUV space to find fast cuts. -# PySceneDetect Detection Algorithm Imports -from scenedetect.detectors.content_detector import ContentDetector -from scenedetect.detectors.threshold_detector import ThresholdDetector + * :mod:`HashDetector `: + Uses perceptual hashing to calculate similarity between adjacent frames. -# Algorithms being ported: -#from scenedetect.detectors.motion_detector import MotionDetector +Detection algorithms are created by implementing the +: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 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 # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # Detection Methods & Algorithms Planned or In Development # # # +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# class EdgeDetector(SceneDetector): -# """Detects fast cuts/slow fades by using edge detection on adjacent frames. -# -# Computes the difference image between subsequent frames after applying a -# Sobel filter (can also use a high-pass or other edge detection filters) and -# comparing the result with a set threshold (may be found using -stats mode). -# Detects both fast cuts and slow fades, although some parameters may need to -# be modified for accurate slow fade detection. -# """ -# def __init__(self): -# super(EdgeDetector, self).__init__() -# # -# # # class DissolveDetector(SceneDetector): # """Detects slow fades (dissolve cuts) via changes in the HSV colour space. # @@ -71,20 +56,17 @@ # # def __init__(self): # super(DissolveDetector, self).__init__() -# # -# # -# class HistogramDetector(SceneDetector): -# """Detects fast cuts via histogram changes between sequential frames. # -# Detects fast cuts between content (using histogram deltas, much like the -# ContentDetector uses HSV colourspace deltas), as well as both fades and -# cuts to/from black (using a threshold, much like the ThresholdDetector). +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# +# class MotionDetector(SceneDetector): +# """Detects motion events in scenes containing a static background. +# +# Uses background subtraction followed by noise removal (via morphological +# opening) to generate a frame score compared against the set threshold. # """ # # def __init__(self): -# super(DissolveDetector, self).__init__() -# # -# # +# super(MotionDetector, self).__init__() +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # - - diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py new file mode 100644 index 00000000..2e98bbf4 --- /dev/null +++ b/scenedetect/detectors/adaptive_detector.py @@ -0,0 +1,143 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +""":class:`AdaptiveDetector` compares the difference in content between adjacent frames similar +to `ContentDetector` except the threshold isn't fixed, but is a rolling average of adjacent frame +changes. This can help mitigate false detections in situations such as fast camera motions. + +This detector is available from the command-line as the `detect-adaptive` command. +""" + +from logging import getLogger + +import numpy as np + +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detectors import ContentDetector + +logger = getLogger("pyscenedetect") + + +class AdaptiveDetector(ContentDetector): + """Two-pass detector that calculates frame scores with ContentDetector, and then applies + a rolling average when processing the result that can help mitigate false detections + in situations such as camera movement. + """ + + ADAPTIVE_RATIO_KEY_TEMPLATE = "adaptive_ratio{luma_only} (w={window_width})" + + def __init__( + self, + adaptive_threshold: float = 3.0, + 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: int | None = None, + ): + """ + Arguments: + adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a + new scene (see frame metric adaptive_ratio in stats file). + min_scene_len: Once a cut is detected, this 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 + register as a new scene. This is calculated the same way that `detect-content` + calculates frame score based on `weights`/`luma_only`/`kernel_size`. + weights: Weight to place on each component when calculating frame score + (`content_val` in a statsfile, the value `threshold` is compared against). + If omitted, the default ContentDetector weights are used. + luma_only: If True, only considers changes in the luminance channel of the video. + Equivalent to specifying `weights` as :data:`ContentDetector.LUMA_ONLY`. + 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. + """ + if window_width < 1: + raise ValueError("window_width must be at least 1.") + + super().__init__( + threshold=255.0, + min_scene_len=0, + weights=weights, + luma_only=luma_only, + kernel_size=kernel_size, + ) + + # 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 + self.window_width = window_width + + self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( + window_width=window_width, luma_only="" if not luma_only else "_lum" + ) + 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: + return self.window_width + + def get_metrics(self) -> list[str]: + return [*super().get_metrics(), self._adaptive_ratio_key] + + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: + super().process_frame(timecode=timecode, frame_img=frame_img) + + # If the parent could not calculate a frame score, there's nothing to buffer. + if self._frame_score is None: + return [] + + # Initialize last scene cut point at the beginning of the frames of interest. + if self._last_cut is None: + self._last_cut = timecode + + required_frames = 1 + (2 * self.window_width) + self._buffer.append((timecode, self._frame_score)) + if not len(self._buffer) >= required_frames: + return [] + self._buffer = self._buffer[-required_frames:] + (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) + + average_is_zero = abs(average_window_score) < 0.00001 + + adaptive_ratio = 0.0 + if not average_is_zero: + adaptive_ratio = min(target_score / average_window_score, 255.0) + elif average_is_zero and target_score >= self.min_content_val: + # 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_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 = (timecode - self._last_cut) >= self.min_scene_len + if threshold_met and min_length_met: + self._last_cut = target_timecode + return [target_timecode] + return [] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index b8e9e737..b666f8c9 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -1,141 +1,243 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. +# 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. # +""":class:`ContentDetector` compares the difference in content between adjacent frames against a +set threshold/score, which if exceeded, triggers a scene cut. -""" PySceneDetect `scenedetect.detectors.content_detector` Module - -This module implements the ContentDetector, which compares the difference -in content between adjacent frames against a set threshold/score, which if -exceeded, triggers a scene cut. +This detector is available from the command-line as the `detect-content` command. """ -# Third-Party Library Imports -import numpy -import cv2 +import math +import typing as ty +from dataclasses import dataclass -# PySceneDetect Library Imports -from scenedetect.scene_detector import SceneDetector +import cv2 +import numpy +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import FlashFilter, SceneDetector -class ContentDetector(SceneDetector): - """Detects fast cuts using changes in colour and intensity between frames. - Since the difference between frames is used, unlike the ThresholdDetector, - only fast cuts are detected with this method. To detect slow fades between - content scenes still using HSV information, use the DissolveDetector. +def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: + """Return the mean average distance in pixel values between `left` and `right`. + Both `left and `right` should be 2 dimensional 8-bit images of the same shape. """ + assert len(left.shape) == 2 and len(right.shape) == 2 + assert left.shape == right.shape + num_pixels: float = float(left.shape[0] * left.shape[1]) + return numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels + + +def _estimated_kernel_size(frame_width: int, frame_height: int) -> int: + """Estimate kernel size based on video resolution.""" + # TODO: This equation is based on manual estimation from a few videos. + # Create a more comprehensive test suite to optimize against. + size: int = 4 + round(math.sqrt(frame_width * frame_height) / 192) + if size % 2 == 0: + size += 1 + return size - def __init__(self, threshold=30.0, min_scene_len=15): - super(ContentDetector, self).__init__() - self.threshold = threshold - self.min_scene_len = min_scene_len # minimum length of any given scene, in frames - self.last_frame = None - self.last_scene_cut = None - self.last_hsv = None - self._metric_keys = ['content_val', 'delta_hue', 'delta_sat', 'delta_lum'] - self.cli_name = 'detect-content' +class ContentDetector(SceneDetector): + """Detects fast cuts using changes in colour and intensity between frames. - def process_frame(self, frame_num, frame_img): - # type: (int, numpy.ndarray) -> List[int] - """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead - of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). + The difference is calculated in the HSV color space, and compared against a set threshold to + determine when a fast cut has occurred. + """ + # 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(ty.NamedTuple): + """Components that make up a frame's score, and their default values.""" + + delta_hue: float = 1.0 + """Difference between pixel hue values of adjacent frames.""" + delta_sat: float = 1.0 + """Difference between pixel saturation values of adjacent frames.""" + delta_lum: float = 1.0 + """Difference between pixel luma (brightness) values of adjacent frames.""" + delta_edges: float = 0.0 + """Difference between calculated edges of adjacent frames. + + Edge differences are typically larger than the other components, so the detection + threshold may need to be adjusted accordingly.""" + + DEFAULT_COMPONENT_WEIGHTS = Components() + """Default component weights. Actual default values are specified in :class:`Components` + to allow adding new components without breaking existing usage.""" + + LUMA_ONLY_WEIGHTS = Components( + delta_hue=0.0, + delta_sat=0.0, + delta_lum=1.0, + delta_edges=0.0, + ) + """Component weights to use if `luma_only` is set.""" + + FRAME_SCORE_KEY = "content_val" + """Key in statsfile representing the final frame score after weighed by specified components.""" + + METRIC_KEYS: ty.ClassVar[list[str]] = [FRAME_SCORE_KEY, *Components._fields] + """All statsfile keys this detector produces.""" + + @dataclass + class _FrameData: + """Data calculated for a given frame.""" + + hue: numpy.ndarray + """Frame hue map [2D 8-bit].""" + sat: numpy.ndarray + """Frame saturation map [2D 8-bit].""" + lum: numpy.ndarray + """Frame luma/brightness map [2D 8-bit].""" + 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: TimecodeLike = 15, + weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, + luma_only: bool = False, + kernel_size: int | None = None, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, + ): + """ Arguments: - frame_num (int): Frame number of frame that is being passed. + threshold: Threshold the average change in pixel intensity must exceed to trigger a 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"``). + 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. + Equivalent to specifying `weights` as :data:`ContentDetector.LUMA_ONLY`. + Overrides `weights` if both are set. + kernel_size: Size of kernel for expanding detected edges. Must be odd integer + greater than or equal to 3. If None, automatically set using video resolution. + filter_mode: Mode to use when filtering cuts to meet `min_scene_len`. + """ + super().__init__() + self._threshold: float = threshold + self._last_frame: ContentDetector._FrameData | None = None + self._weights: ContentDetector.Components = weights + if luma_only: + self._weights = ContentDetector.LUMA_ONLY_WEIGHTS + self._kernel: numpy.ndarray | None = None + if kernel_size is not None: + if kernel_size < 3 or kernel_size % 2 == 0: + raise ValueError("kernel_size must be odd integer >= 3") + self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) + self._frame_score: 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 _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. + # TODO: Investigate methods of performing cheaper alternatives, e.g. shifting or resizing + # the frame to simulate camera movement, using optical flow, etc... + + # Convert image into HSV colorspace. + hue, sat, lum = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) + + # Performance: Only calculate edges if we have to. + calculate_edges: bool = (self._weights.delta_edges > 0.0) or self.stats_manager is not None + edges = self._detect_edges(lum) if calculate_edges else None + + if self._last_frame is None: + # Need another frame to compare with for score calculation. + self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) + return 0.0 + + score_components = ContentDetector.Components( + delta_hue=_mean_pixel_distance(hue, self._last_frame.hue), + 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 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, 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(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, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> list[FrameTimecode]: + """Process the next frame. `frame_num` is assumed to be sequential. - 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. + 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. """ - cut_list = [] - metric_keys = self._metric_keys - _unused = '' - - if self.last_frame is not None: - # Change in average of HSV (hsv), (h)ue only, (s)aturation only, (l)uminance only. - delta_hsv_avg, delta_h, delta_s, delta_v = 0.0, 0.0, 0.0, 0.0 - - if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num, metric_keys)): - delta_hsv_avg, delta_h, delta_s, delta_v = self.stats_manager.get_metrics( - frame_num, metric_keys) - - else: - num_pixels = frame_img.shape[0] * frame_img.shape[1] - curr_hsv = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) - last_hsv = self.last_hsv - if not last_hsv: - last_hsv = cv2.split(cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2HSV)) - - delta_hsv = [0, 0, 0, 0] - for i in range(3): - num_pixels = curr_hsv[i].shape[0] * curr_hsv[i].shape[1] - curr_hsv[i] = curr_hsv[i].astype(numpy.int32) - last_hsv[i] = last_hsv[i].astype(numpy.int32) - delta_hsv[i] = numpy.sum( - numpy.abs(curr_hsv[i] - last_hsv[i])) / float(num_pixels) - delta_hsv[3] = sum(delta_hsv[0:3]) / 3.0 - delta_h, delta_s, delta_v, delta_hsv_avg = delta_hsv - - if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, { - metric_keys[0]: delta_hsv_avg, - metric_keys[1]: delta_h, - metric_keys[2]: delta_s, - metric_keys[3]: delta_v}) - - self.last_hsv = curr_hsv - - if delta_hsv_avg >= self.threshold: - if self.last_scene_cut is None or ( - (frame_num - self.last_scene_cut) >= self.min_scene_len): - cut_list.append(frame_num) - self.last_scene_cut = frame_num - - if self.last_frame is not None and self.last_frame is not _unused: - del self.last_frame - - # If we have the next frame computed, don't copy the current frame - # into last_frame since we won't use it on the next call anyways. - if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num+1, metric_keys)): - self.last_frame = _unused - else: - self.last_frame = frame_img.copy() - - return cut_list - - - #def post_process(self, frame_num): - # """ Not used for ContentDetector, as unlike ThresholdDetector, cuts - # are always written as they are found. - # """ - # return [] + 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(timecode=timecode, above_threshold=above_threshold) + + def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: + """Detect edges using the luma channel of a frame. + + Arguments: + lum: 2D 8-bit image representing the luma channel of a frame. + + Returns: + 2D 8-bit image of the same size as the input, where pixels with values of 255 + represent edges, and all other pixels are 0. + """ + # Initialize kernel. + if self._kernel is None: + kernel_size = _estimated_kernel_size(lum.shape[1], lum.shape[0]) + self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) + + # Estimate levels for thresholding. + # TODO: Add config file entries for sigma, aperture/kernel size, etc. + sigma: float = 1.0 / 3.0 + median = numpy.median(lum) + low = int(max(0, (1.0 - sigma) * median)) + high = int(min(255, (1.0 + sigma) * median)) + + # Calculate edges using Canny algorithm, and reduce noise by dilating the edges. + # This increases edge overlap leading to improved robustness against noise and slow + # camera movement. Note that very large kernel sizes can negatively affect accuracy. + edges = cv2.Canny(lum, low, high) + return cv2.dilate(edges, self._kernel) + + @property + def event_buffer_length(self) -> int: + return self._flash_filter.max_behind diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py new file mode 100644 index 00000000..395766c9 --- /dev/null +++ b/scenedetect/detectors/hash_detector.py @@ -0,0 +1,151 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +""":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 detector is available from the command-line interface by using the `detect-hash` command. +""" + +import cv2 +import numpy + +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import SceneDetector + + +class HashDetector(SceneDetector): + """Detects cuts using a perceptual hashing algorithm. Applies a direct cosine transform (DCT) + and lowpass filter, followed by binary thresholding on the median. See references below: + + 1. https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html + 2. https://github.com/JohannesBuchner/imagehash + + Arguments: + threshold: Value from 0.0 and 1.0 representing the relative hamming distance between + the perceptual hashes of adjacent frames. A distance of 0 means the image is the same, + and 1 means no correlation. Smaller threshold values thus require more correlation, + making the detector more sensitive. The hamming distance is divided by `size` x `size` + before comparing to `threshold` for normalization. + size: Size of square of low frequency data to use for the DCT + lowpass: How much high frequency information to filter from the DCT. A value of 2 means + keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... + min_scene_len: Once a cut is detected, this 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.35, + size: int = 8, + lowpass: int = 2, + min_scene_len: TimecodeLike = 15, + ): + super().__init__() + self._threshold = threshold + self._min_scene_len = min_scene_len + self._size = size + self._size_sq = float(size * size) + self._factor = lowpass + self._last_frame: numpy.ndarray | None = 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 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.""" + + 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 = timecode + + # We can only start detecting once we have a frame to compare with. + if self._last_frame is not None: + # We obtain the change in hash value between subsequent frames. + curr_hash = self.hash_frame( + frame_img=frame_img, hash_size=self._size, factor=self._factor + ) + + last_hash = self._last_hash + + if last_hash.size == 0: + # Calculate hash of last frame + last_hash = self.hash_frame( + frame_img=self._last_frame, hash_size=self._size, factor=self._factor + ) + + # Hamming distance is calculated to compare to last frame + hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) + + # Normalize based on size of the hash + hash_dist_norm = hash_dist / self._size_sq + + if self.stats_manager is not None: + 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 ( + (timecode - self._last_scene_cut) >= self._min_scene_len + ): + cut_list.append(timecode) + self._last_scene_cut = timecode + + self._last_frame = frame_img.copy() + + return cut_list + + @staticmethod + def hash_frame(frame_img, hash_size, factor) -> numpy.ndarray: + """Calculates the perceptual hash of a frame and returns it. Based on phash from + https://github.com/JohannesBuchner/imagehash. + """ + + # Transform to grayscale + gray_img = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) + + # Resize image to square to help with DCT + imsize = hash_size * factor + resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) + + # Check to avoid dividing by zero + max_value = numpy.max(numpy.max(resized_img)) + if max_value == 0: + # Just set the max to 1 to not change the values + max_value = 1 + + # Calculate discrete cosine tranformation of the image + 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(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 + + return hash_img diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py new file mode 100644 index 00000000..0018606e --- /dev/null +++ b/scenedetect/detectors/histogram_detector.py @@ -0,0 +1,168 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.scenedetect.scenedetect.com/ ] +# [ Docs: http://manual.scenedetect.scenedetect.com/ ] +# [ 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. +# +""":py:class:`HistogramDetector` compares the difference in the YUV histograms of subsequent +frames. If the difference exceeds a given threshold, a cut is detected. + +This detector is available from the command-line as the `detect-hist` command. +""" + +import typing as ty + +import cv2 +import numpy + +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: ty.ClassVar[list[str]] = ["hist_diff"] + + 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 differences imply greater + change in content, so larger threshold values are less sensitive to cuts. + bins: Number of bins to use for the histogram. + min_scene_len: Once a cut is detected, this 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 + # between -1.0 and 1.0. + self._threshold = max(0.0, min(1.0, 1.0 - threshold)) + self._bins = bins + self._min_scene_len = min_scene_len + self._last_hist = None + self._last_cut = None + self._metric_key = f"hist_diff [bins={self._bins}]" + + 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 difference is computed using the correlation metric. + + Arguments: + timecode: Timecode of the frame that is being passed. + frame_img: Decoded frame image (numpy.ndarray) to perform scene + detection on. + + Returns: + 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 = [] + + np_data_type = frame_img.dtype + + if np_data_type != numpy.uint8: + raise ValueError("Image must be 8-bit rgb for HistogramDetector") + + if frame_img.shape[2] != 3: + 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_cut: + self._last_cut = timecode + + hist = self.calculate_histogram(frame_img, bins=self._bins) + + # We can only start detecting once we have a frame to compare with. + if self._last_hist is not None: + # TODO: We can have EMA of histograms to make it more robust + # ema_hist = alpha * hist + (1 - alpha) * ema_hist + + # Compute histogram difference between frames + hist_diff = cv2.compareHist(self._last_hist, hist, cv2.HISTCMP_CORREL) + + # 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). + # 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. + if hist_diff <= self._threshold and ( + (timecode - self._last_cut) >= self._min_scene_len + ): + 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(timecode, {self._metric_key: hist_diff}) + + self._last_hist = hist + + return cut_list + + @staticmethod + def calculate_histogram( + frame_img: numpy.ndarray, bins: int = 256, normalize: bool = True + ) -> numpy.ndarray: + """ + Calculates and optionally normalizes the histogram of the luma (Y) channel of an image + converted from BGR to YUV color space. + + This function extracts the Y channel from the given BGR image, computes its histogram with + the specified number of bins, and optionally normalizes this histogram to have a sum of one + across all bins. + + 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: + 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)) + + # Create the histogram with a bin for every rgb value + hist = cv2.calcHist([y], [0], None, [bins], [0, 256]) + + if normalize: + # Normalize the histogram + hist = cv2.normalize(hist, hist).flatten() + + return hist + + def get_metrics(self) -> list[str]: + return [self._metric_key] diff --git a/scenedetect/detectors/motion_detector.py b/scenedetect/detectors/motion_detector.py deleted file mode 100644 index d86787c4..00000000 --- a/scenedetect/detectors/motion_detector.py +++ /dev/null @@ -1,106 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# -# 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, pytest, mkvmerge, and ffmpeg. 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. -# - -# Third-Party Library Imports -import cv2 -import numpy - - -from scenedetect.scene_detector import SceneDetector - -class MotionDetector(SceneDetector): - """Detects motion events in scenes containing a static background. - - Uses background subtraction followed by noise removal (via morphological - opening) to generate a frame score compared against the set threshold. - - Attributes: - threshold: floating point value compared to each frame's score, which - represents average intensity change per pixel (lower values are - more sensitive to motion changes). Default 0.5, must be > 0.0. - num_frames_post_scene: Number of frames to include in each motion - event after the frame score falls below the threshold, adding any - subsequent motion events to the same scene. - kernel_size: Size of morphological opening kernel for noise removal. - Setting to -1 (default) will auto-compute based on video resolution - (typically 3 for SD, 5-7 for HD). Must be an odd integer > 1. - """ - def __init__(self, threshold = 0.50, num_frames_post_scene = 30, - kernel_size = -1): - """Initializes motion-based scene detector object.""" - # Requires porting to v0.5 API. - raise NotImplementedError() - - self.threshold = float(threshold) - self.num_frames_post_scene = int(num_frames_post_scene) - - self.kernel_size = int(kernel_size) - if self.kernel_size < 0: - # Set kernel size when process_frame first runs based on - # video resolution (480p = 3x3, 720p = 5x5, 1080p = 7x7). - pass - - self.bg_subtractor = cv2.createBackgroundSubtractorMOG2( - detectShadows = False ) - - self.last_frame_score = 0.0 - - self.in_motion_event = False - self.first_motion_frame_index = -1 - self.last_motion_frame_index = -1 - self.cli_name = 'detect-motion' - return - - def process_frame(self, frame_num, frame_img, frame_metrics, scene_list): - - # Value to return indiciating if a scene cut was found or not. - cut_detected = False - - frame_grayscale = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - masked_frame = self.bg_subtractor.apply(frame_grayscale) - - kernel = numpy.ones((self.kernel_size, self.kernel_size), numpy.uint8) - filtered_frame = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel) - - frame_score = numpy.sum(filtered_frame) / float( - filtered_frame.shape[0] * filtered_frame.shape[1] ) - - return cut_detected - - def post_process(self, scene_list, frame_num): - """Writes the last scene if the video ends while in a motion event. - """ - - # 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. - - if self.in_motion_event: - # Write new scene based on first and last motion event frames. - pass - return self.in_motion_event - - diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index cf5ad7e1..945bc987 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -1,214 +1,173 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. +# 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. # +""":class:`ThresholdDetector` uses a set intensity as a threshold to detect cuts, which are +triggered when the average pixel intensity exceeds or falls below this threshold. -""" PySceneDetect `scenedetect.detectors.threshold_detector` Module - -This module implements the ThresholdDetector, which uses a set intensity level -to detect scene cuts when the average frame intensity passes the set threshold. +This detector is available from the command-line as the `detect-threshold` command. """ -# Third-Party Library Imports -import numpy - -# PySceneDetect Library Imports -from scenedetect.scene_detector import SceneDetector - - -## -## ThresholdDetector Helper Functions -## +import typing as ty +import warnings +from enum import Enum +from logging import getLogger -def compute_frame_average(frame): - """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. +import numpy - Returns: - Floating point value representing average pixel intensity. - """ - 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 +from scenedetect.common import FrameTimecode, TimecodeLike +from scenedetect.detector import SceneDetector +logger = getLogger("pyscenedetect") -## -## ThresholdDetector Class Implementation -## class ThresholdDetector(SceneDetector): """Detects fast cuts/slow fades in from and out to a given threshold level. Detects both fast cuts and slow fades so long as an appropriate threshold is chosen (especially taking into account the minimum grey/black level). - - Attributes: - 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_percent: Float between 0.0 and 1.0 which represents the minimum - percent of pixels in a frame that must meet the threshold value in - order to trigger a fade in/out. - min_scene_len: Unsigned integer greater than 0 representing the - minimum length, in frames, of a scene (or subsequent scene cut). - 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 - right at the position where the threshold is passed). - add_final_scene: Boolean indicating if the video ends on a fade-out to - generate an additional scene at this timecode. - block_size: Number of rows in the image to sum per iteration (can be - tuned to increase performance in some cases; should be computed - programmatically in the future). """ - def __init__(self, threshold=12, min_percent=0.95, min_scene_len=15, - fade_bias=0.0, add_final_scene=False, block_size=8): - """Initializes threshold-based scene detector object.""" - super(ThresholdDetector, self).__init__() + class Method(Enum): + """Method for ThresholdDetector to use when comparing frame brightness to the threshold.""" + + FLOOR = 0 + """Fade out happens when frame brightness falls below threshold.""" + CEILING = 1 + """Fade out happens when frame brightness rises above threshold.""" + + THRESHOLD_VALUE_KEY = "average_rgb" + + def __init__( + self, + threshold: float = 12, + min_scene_len: TimecodeLike = 15, + fade_bias: float = 0.0, + add_final_scene: bool = False, + method: Method = Method.FLOOR, + block_size=None, + ): + """ + Arguments: + threshold: 8-bit intensity value that each pixel value (R, G, and B) + must be <= to in order to trigger a fade in/out. + min_scene_len: Once a cut is detected, this 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 + right at the position where the threshold is passed). + add_final_scene: Boolean indicating if the video ends on a fade-out to + generate an additional scene at this timecode. + method: How to treat `threshold` when detecting fade events. + block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. + """ + if block_size is not None: + warnings.warn( + "The `block_size` argument is deprecated and will be removed in v0.8.", + DeprecationWarning, + stacklevel=2, + ) + + super().__init__() self.threshold = int(threshold) + self.method = ThresholdDetector.Method(method) self.fade_bias = fade_bias - self.min_percent = min_percent self.min_scene_len = min_scene_len - self.last_frame_avg = None - self.last_scene_cut = None + self.processed_frame = False + 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 - 'type': None # type of fade, can be either 'in' or 'out' + 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.block_size = block_size - self._metric_keys = ['delta_rgb'] - self.cli_name = 'detect-threshold' + self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] - def frame_under_threshold(self, frame): - """Check if the frame is below (true) or above (false) the threshold. + def get_metrics(self) -> list[str]: + return self._metric_keys - Instead of using the average, we check all pixel values (R, G, and B) - meet the given threshold (within the minimum percent). This ensures - that the threshold is not exceeded while maintaining some tolerance for - compression and noise. + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> list[FrameTimecode]: + """Process the next frame. - This is the algorithm used for absolute mode of the threshold detector. + Arguments: + timecode: FrameTimecode of the current frame position. + frame_img (numpy.ndarray or None): Video frame corresponding to `timecode`. Returns: - Boolean, True if the number of pixels whose R, G, and B values are - all <= the threshold is within min_percent pixels, or False if not. - """ - # First we compute the minimum number of pixels that need to meet the - # threshold. Internally, we check for values greater than the threshold - # as it's more likely that a given frame contains actual content. This - # is done in blocks of rows, so in many cases we only have to check a - # small portion of the frame instead of inspecting every single pixel. - num_pixel_values = float(frame.shape[0] * frame.shape[1] * frame.shape[2]) - min_pixels = int(num_pixel_values * (1.0 - self.min_percent)) - - curr_frame_amt = 0 - curr_frame_row = 0 - - while curr_frame_row < frame.shape[0]: - # Add and total the number of individual pixel values (R, G, and B) - # in the current row block that exceed the threshold. - curr_frame_amt += int(numpy.sum( - frame[curr_frame_row : curr_frame_row + self.block_size, :, :] > self.threshold)) - # If we've already exceeded the most pixels allowed to be above the - # threshold, we can skip processing the rest of the pixels. - if curr_frame_amt > min_pixels: - return False - curr_frame_row += self.block_size - return True - - def process_frame(self, frame_num, frame_img): - # type: (int, Optional[numpy.ndarray]) -> List[int] - """ - Args: - frame_num (int): Frame number of frame that is being passed. - frame_img (numpy.ndarray or None): Decoded frame image (numpy.ndarray) to perform - scene detection with. 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. + 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 = timecode - # Compare the # of pixels under threshold in current_frame & last_frame. - # If absolute value of pixel intensity delta is above the threshold, - # then we trigger a new scene cut/break. - - # List of cuts to return. - 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) - frame_avg = 0.0 - - if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num, self._metric_keys)): - frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] + if (self.stats_manager is not None) and ( + self.stats_manager.metrics_exist(timecode, self._metric_keys) + ): + 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.last_frame_avg is not None: - if self.last_fade['type'] == 'in' and self.frame_under_threshold(frame_img): + if self.processed_frame: + if self.last_fade["type"] == "in" and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) + or (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold) + ): # Just faded out of a scene, wait for next fade in. - self.last_fade['type'] = 'out' - self.last_fade['frame'] = frame_num - elif self.last_fade['type'] == 'out' and not self.frame_under_threshold(frame_img): - # Just faded into a new scene, compute timecode for the scene - # split based on the fade bias. - f_in = frame_num - f_out = self.last_fade['frame'] - f_split = int((f_in + f_out + int(self.fade_bias * (f_in - f_out))) / 2) + self.last_fade["type"] = "out" + 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 self.last_scene_cut is None or ( - (frame_num - self.last_scene_cut) >= self.min_scene_len): - cut_list.append(f_split) - self.last_scene_cut = frame_num - self.last_fade['type'] = 'in' - self.last_fade['frame'] = frame_num + 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. 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"] + duration_frames = timecode.frame_num - f_out.frame_num + split_frame_num = f_out.frame_num + round( + duration_frames * (1.0 + self.fade_bias) / 2.0 + ) + cuts.append(FrameTimecode(split_frame_num, fps=timecode)) + self.last_scene_cut = timecode + self.last_fade["type"] = "in" + self.last_fade["frame"] = timecode else: - self.last_fade['frame'] = 0 - if self.frame_under_threshold(frame_img): - self.last_fade['type'] = 'out' + self.last_fade["frame"] = timecode + if frame_avg < self.threshold: + self.last_fade["type"] = "out" else: - self.last_fade['type'] = 'in' - # Before returning, we keep track of the last frame average (can also - # be used to compute fades independently of the last fade type). - self.last_frame_avg = frame_avg - return cut_list + self.last_fade["type"] = "in" + self.processed_frame = True + return cuts - def post_process(self, frame_num): + 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 @@ -220,9 +179,13 @@ def post_process(self, frame_num): # 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 = [] - if self.last_fade['type'] == 'out' and self.add_final_scene and ( - self.last_scene_cut is None or - (frame_num - self.last_scene_cut) >= self.min_scene_len): - cut_times.append(self.last_fade['frame']) - return 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_fade["frame"] is not None + and elapsed >= self.min_scene_len + ): + 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 295c0b26..8fcb5e15 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -1,471 +1,22 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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/ -# -# 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. -# - -""" PySceneDetect ``scenedetect.frame_timecode`` Module - -This module contains the :py:class:`FrameTimecode` object, 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. - -See the following examples, or the :py:class:`FrameTimecode constructor `. - -Unit tests for the FrameTimecode object can be found in `tests/test_timecode.py`. -""" - -# Standard Library Imports -import math - -# PySceneDetect Library Imports -from scenedetect.platform import STRING_TYPE - - -MINIMUM_FRAMES_PER_SECOND_FLOAT = 1.0 / 1000.0 -MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT = 1.0 / 100000 - - -class FrameTimecode(object): - """ Object for frame-based timecodes, using the video framerate - to compute back and forth between frame number and second/timecode formats. - - The timecode argument is valid only if it complies with one of the following - three types/formats: - - 1) string: standard timecode HH:MM:SS[.nnn]: - `str` in form 'HH:MM:SS' or 'HH:MM:SS.nnn', or - `list`/`tuple` in form [HH, MM, SS] or [HH, MM, SS.nnn] - 2) float: number of seconds S[.SSS], where S >= 0.0: - `float` in form S.SSS, or - `str` in form 'Ss' or 'S.SSSs' (e.g. '5s', '1.234s') - 3) int: Exact number of frames N, where N >= 0: - `int` in form `N`, or - `str` in form 'N' - - Arguments: - timecode (str, float, int, or FrameTimecode): A timecode or frame - number, given in any of the above valid formats/types. This - argument is always required. - fps (float, or FrameTimecode, conditionally required): The framerate - to base all frame to time arithmetic on (if FrameTimecode, copied - from the passed framerate), to allow frame-accurate arithmetic. The - framerate must be the same when combining FrameTimecode objects - in operations. This argument is always required, unless **timecode** - is a FrameTimecode. - Raises: - TypeError: Thrown if timecode is wrong type/format, or if fps is None - or a type other than int or float. - ValueError: Thrown when specifying a negative timecode or framerate. - """ - - def __init__(self, timecode=None, fps=None): - # type: (Union[int, float, str, FrameTimecode], float, - # Union[int, float, str, FrameTimecode]) - # 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). - 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.') - elif (isinstance(fps, int) and not fps > 0) or ( - isinstance(fps, float) and not fps >= MINIMUM_FRAMES_PER_SECOND_FLOAT): - 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, STRING_TYPE)): - self.frame_num = self._parse_timecode_string(timecode) - else: - self.frame_num = self._parse_timecode_number(timecode) - - # Alternative formats under consideration (require unit tests before adding): - - # Standard timecode in list format [HH, MM, SS.nnn] - #elif isinstance(timecode, (list, tuple)) and len(timecode) == 3: - # if any(not isinstance(x, (int, float)) for x in timecode): - # raise ValueError('Timecode components must be of type int/float.') - # hrs, mins, secs = timecode - # if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 - # and secs < 60): - # raise ValueError('Timecode components must be positive.') - # secs += (((hrs * 60.0) + mins) * 60.0) - # self.frame_num = int(secs * self.framerate) - - - def get_frames(self): - # type: () -> 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 :py:meth:`get_seconds` method). - - If using to compare a :py: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 int(self.frame_num) - - - def get_framerate(self): - # type: () -> 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): - # type: (float) -> bool - """ Equal Framerate: Determines if the passed framerate is equal to that of the - FrameTimecode object. - - Arguments: - fps: Framerate (float) to compare against within the precision constant - MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT defined in this module. - - Returns: - bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. - - """ - return math.fabs(self.framerate - fps) < MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT - - - def get_seconds(self): - # type: () -> float - """ Get the frame's position in number of seconds. - - If using to compare a :py: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 - - - def get_timecode(self, precision=3, use_rounding=True): - # type: (int, bool) -> 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: True (default) to round the output to the desired 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() - base = 60.0 * 60.0 - hrs = int(secs / base) - secs -= (hrs * base) - base = 60.0 - mins = int(secs / base) - secs -= (mins * base) - # Convert seconds into string based on required precision. - if precision > 0: - if use_rounding: - secs = round(secs, precision) - #secs = math.ceil(secs * (10**precision)) / float(10**precision) - msec = format(secs, '.%df' % precision)[-precision:] - secs = '%02d.%s' % (int(secs), msec) - else: - secs = '%02d' % int(round(secs, 0)) if use_rounding else '%02d' % int(secs) - # Return hours, minutes, and seconds as a formatted timecode string. - return '%02d:%02d:%s' % (hrs, mins, secs) - - - def _seconds_to_frames(self, seconds): - # type: (float) -> int - """ Converts 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 int(seconds * self.framerate) - - - def _parse_timecode_number(self, timecode): - # type: (Union[int, float]) -> int - """ Parses 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, timecode_string): - # type: (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.0s' are all possible valid values, all representing - a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). - - Raises: - TypeError, ValueError - """ - if self.framerate is None: - raise TypeError('self.framerate must be set before calling _parse_timecode_string.') - # Number of seconds S - if timecode_string.endswith('s'): - secs = timecode_string[:-1] - if not secs.replace('.', '').isdigit(): - raise ValueError('All characters in timecode seconds string must be digits.') - secs = float(secs) - if secs < 0.0: - raise ValueError('Timecode seconds value must be positive.') - return int(secs * self.framerate) - # Exact number of frames N - elif timecode_string.isdigit(): - timecode = int(timecode_string) - if timecode < 0: - raise ValueError('Timecode frame number must be positive.') - return timecode - # Standard timecode in string format 'HH:MM:SS[.nnn]' - else: - tc_val = timecode_string.split(':') - if not (len(tc_val) == 3 and tc_val[0].isdigit() and tc_val[1].isdigit() - and tc_val[2].replace('.', '').isdigit()): - raise ValueError('Unrecognized or improperly formatted timecode string.') - hrs, mins = int(tc_val[0]), int(tc_val[1]) - secs = float(tc_val[2]) if '.' in tc_val[2] else int(tc_val[2]) - 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.0) + mins) * 60.0) - return int(secs * self.framerate) - - - def __iadd__(self, other): - # type: (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) - 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): - # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode - to_return = FrameTimecode(timecode=self) - to_return += other - return to_return - - - def __isub__(self, other): - # type: (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) - else: - raise TypeError('Unsupported type for performing subtraction with FrameTimecode.') - if self.frame_num < 0: - self.frame_num = 0 - return self - - - def __sub__(self, other): - # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode - to_return = FrameTimecode(timecode=self) - to_return -= other - return to_return - - - def __eq__(self, other): - # type: (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.') - elif other is None: - return False - else: - raise TypeError('Unsupported type for performing == with FrameTimecode.') - - - def __ne__(self, other): - # type: (Union[int, float, str, FrameTimecode]) -> bool - return not self == other - - - def __lt__(self, other): - # type: (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.') - #elif other is None: - # return False - else: - raise TypeError('Unsupported type for performing < with FrameTimecode.') - - - def __le__(self, other): - # type: (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.') - #elif other is None: - # return False - else: - raise TypeError('Unsupported type for performing <= with FrameTimecode.') - - - def __gt__(self, other): - # type: (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.') - #elif other is None: - # return False - else: - raise TypeError('Unsupported type (%s) for performing > with FrameTimecode.' % - type(other).__name__) - - - def __ge__(self, other): - # type: (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.') - #elif other is None: - # return False - else: - raise TypeError('Unsupported type for performing >= with FrameTimecode.') - - - - def __int__(self): - return self.frame_num - - def __float__(self): - return self.get_seconds() +"""DEPRECATED""" - def __str__(self): - return self.get_timecode() +import warnings - def __repr__(self): - return 'FrameTimecode(frame=%d, fps=%f)' % (self.frame_num, self.framerate) +warnings.warn( + "The `frame_timecode` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) +from scenedetect.common import * # noqa: E402, F403 diff --git a/scenedetect/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 b8971c43..9a783ea2 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -1,196 +1,423 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect `scenedetect.platform` Module - -This file contains all platform/library/OS-specific compatibility fixes, -intended to improve the systems that are able to run PySceneDetect, and allow -for maintaining backwards compatibility with existing libraries going forwards. -Other helper functions related to the detection of the appropriate dependency -DLLs on Windows and getting uniform line-terminating csv reader/writer objects -are also included in this module. +"""``scenedetect.platform`` Module -With respect to the Python standard library itself and Python 2 versus 3, -this module adds compatibility wrappers for Python's Queue/queue (Python 2/3, -respectively) as scenedetect.platform.queue. - -For OpenCV 2.x, the scenedetect.platform module also makes a copy of the -OpenCV VideoCapture property constants from the cv2.cv namespace directly -to the cv2 namespace. This ensures that the cv2 API is consistent -with those changes made to it in OpenCV 3.0 and above. - -This module also includes an alias for the unicode/string types in Python 2/3 -as STRING_TYPE intended to help with parsing string types from the CLI parser. +This module contains all platform/library specific compatibility fixes, as well as some utility +functions to handle logging and invoking external commands. """ -# Standard Library Imports -from __future__ import print_function -import sys +import importlib.metadata +import logging import os +import os.path import platform -import struct -import csv +import re +import string +import subprocess +import sys -# Third-Party Library Imports import cv2 -# pylint: disable=unused-import - +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`.""" ## -## Python 2/3 Queue/queue Library (scenedetect.platform.queue) +## tqdm Library ## -if sys.version_info[0] == 2: - import Queue as queue -else: - import queue +class FakeTqdmObject: + """Provides a no-op tqdm-like object.""" -## -## tqdm Library (scenedetect.platform.tqdm will be tqdm object or None) -## + def __init__(self, **kwargs): + """No-op.""" -try: - from tqdm import tqdm -except ImportError: - tqdm = None + def update(self, n=1): + """No-op.""" + + def close(self): + """No-op.""" + + def set_description(self, desc=None, refresh=True): + """No-op.""" + + +class FakeTqdmLoggingRedirect: + """Provides a no-op tqdm context manager for redirecting log messages.""" + def __init__(self, **kwargs): + """No-op.""" -# pylint: enable=unused-import + def __enter__(self): + """No-op.""" + def __exit__(self, type, value, traceback): + """No-op.""" + + +# Try to import tqdm and the logging redirect, otherwise provide fake implementations.. +try: + from tqdm import tqdm + from tqdm.contrib.logging import logging_redirect_tqdm +except ModuleNotFoundError: + tqdm = FakeTqdmObject + logging_redirect_tqdm = FakeTqdmLoggingRedirect ## -## click/Command-Line Interface String Type +## OpenCV imwrite Supported Image Types & Quality/Compression Parameters ## -# String type (used to allow FrameTimecode object to take both unicode and native -# string objects when being constructed via scenedetect.platform.STRING_TYPE). -# pylint: disable=invalid-name, undefined-variable -if sys.version_info[0] == 2: - STRING_TYPE = unicode -else: - STRING_TYPE = str -# pylint: enable=invalid-name, undefined-variable + +# TODO: Move this into scene_manager. +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. + + Returns: + Dictionary of supported image formats/extensions ('jpg', 'png', etc...) mapped to the + respective OpenCV quality or compression parameter as {'jpg': cv2.IMWRITE_JPEG_QUALITY, + 'png': cv2.IMWRITE_PNG_COMPRESSION, ...}. Parameter will be None if not found on the + current system library (e.g. {'jpg': None}). + """ + + def _get_cv2_param(param_name: str) -> int | None: + if param_name.startswith("CV_"): + param_name = param_name[3:] + try: + return getattr(cv2, param_name) + except AttributeError: + return None + + return { + "jpg": _get_cv2_param("IMWRITE_JPEG_QUALITY"), + "png": _get_cv2_param("IMWRITE_PNG_COMPRESSION"), + "webp": _get_cv2_param("IMWRITE_WEBP_QUALITY"), + } ## -## OpenCV 2.x Compatibility Fix +## File I/O ## -# Compatibility fix for OpenCV v2.x (copies CAP_PROP_* properties from the -# cv2.cv namespace to the cv2 namespace, as the cv2.cv namespace was removed -# with the release of OpenCV 3.0). -# pylint: disable=c-extension-no-member -if cv2.__version__[0] == '2' or not ( - cv2.__version__[0].isdigit() and int(cv2.__version__[0]) >= 3): - cv2.CAP_PROP_FRAME_WIDTH = cv2.cv.CV_CAP_PROP_FRAME_WIDTH - cv2.CAP_PROP_FRAME_HEIGHT = cv2.cv.CV_CAP_PROP_FRAME_HEIGHT - cv2.CAP_PROP_FPS = cv2.cv.CV_CAP_PROP_FPS - cv2.CAP_PROP_POS_MSEC = cv2.cv.CV_CAP_PROP_POS_MSEC - cv2.CAP_PROP_POS_FRAMES = cv2.cv.CV_CAP_PROP_POS_FRAMES - cv2.CAP_PROP_FRAME_COUNT = cv2.cv.CV_CAP_PROP_FRAME_COUNT -# pylint: enable=c-extension-no-member + +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. + + E.g. /tmp/foo.bar -> foo""" + file_name = os.path.basename(os.fspath(file_path)) + if not include_extension: + 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: 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. + + If file_path is already an absolute path, then output_directory is ignored. + + Arguments: + file_path: File name to get path for. If file_path is an absolute + path (e.g. starts at a drive/root), no modification of the path + is performed, only ensuring that all output directories are created. + output_dir: An optional output directory to override the + directory of file_path if it is relative to the working directory. + + Returns: + 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(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) + return file_path ## -## OpenCV DLL Check Function (Windows Only) +## Logging ## -def check_opencv_ffmpeg_dll(): - # type: () -> bool - """ Check OpenCV FFmpeg DLL: Checks if OpenCV video I/O support is available, - on Windows only, by checking for the appropriate opencv_ffmpeg*.dll file. - On non-Windows systems always returns True, or for OpenCV versions that do - not follow the X.Y.Z version numbering pattern. Thus there may be false - positives (True) with this function, but not false negatives (False). - In those cases, PySceneDetect will report that it could not open the - video file, and for Windows users, also gives an additional warning message - that the error may be due to the missing DLL file. +def init_logger( + 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 + every time this function is invoked. - Returns: - (bool) True if OpenCV video support is detected (e.g. the appropriate - opencv_ffmpegXYZ.dll file is in PATH), False otherwise. + Arguments: + log_level: Verbosity of log messages. Should be one of [logging.INFO, logging.DEBUG, + logging.WARNING, logging.ERROR, logging.CRITICAL]. + show_stdout: If True, add handler to show log messages on stdout (default: False). + log_file: If set, add handler to dump debug log messages to given file path. """ - if platform.system() == 'Windows' and ( - cv2.__version__[0].isdigit() and cv2.__version__.find('.') > 0): - is_64_bit_str = '_64' if struct.calcsize("P") == 8 else '' - dll_filename = 'opencv_ffmpeg{OPENCV_VERSION}{IS_64_BIT}.dll'.format( - OPENCV_VERSION=cv2.__version__.replace('.', ''), - IS_64_BIT=is_64_bit_str) - return any([os.path.exists(os.path.join(path_path, dll_filename)) - for path_path in os.environ['PATH'].split(';')]), dll_filename - return True + # Format of log messages depends on verbosity. + INFO_TEMPLATE = "[PySceneDetect] %(message)s" + DEBUG_TEMPLATE = "%(levelname)s: %(module)s.%(funcName)s(): %(message)s" + # Get the named logger and remove any existing handlers. + logger_instance = logging.getLogger("pyscenedetect") + logger_instance.handlers = [] + logger_instance.setLevel(log_level) + # Add stdout handler if required. + if show_stdout: + handler = logging.StreamHandler(stream=sys.stdout) + handler.setLevel(log_level) + handler.setFormatter( + logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE) + ) + logger_instance.addHandler(handler) + # Add debug log handler if required. + if log_file: + log_file = get_and_create_path(log_file) + handler = logging.FileHandler(log_file) + handler.setLevel(logging.DEBUG) + handler.setFormatter(logging.Formatter(fmt=DEBUG_TEMPLATE)) + logger_instance.addHandler(handler) ## -## OpenCV imwrite Supported Image Types & Quality/Compression Parameters +## Running External Commands ## -def _get_cv2_param(param_name): - # type: (str) -> Union[int, None] - if param_name.startswith('CV_'): - param_name = param_name[3:] - try: - return getattr(cv2, param_name) - except AttributeError: - return None +class CommandTooLong(Exception): + """Raised if the length of a command line argument exceeds the limit allowed on Windows.""" -def get_cv2_imwrite_params(): - # type: () -> Dict[str, Union[int, None]] - """ Get OpenCV imwrite Params: Returns a dict of supported image formats and - their associated quality/compression parameter. + +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. + + See https://github.com/Breakthrough/PySceneDetect/issues/164 for details. + + Arguments: + args: List of strings to pass to subprocess.call(). Returns: - (Dict[str, int]) Dictionary of image formats/extensions ('jpg', - 'png', etc...) mapped to the respective OpenCV quality or - compression parameter (e.g. 'jpg' -> cv2.IMWRITE_JPEG_QUALITY, - 'png' -> cv2.IMWRITE_PNG_COMPRESSION).. + Return code of command. + + Raises: + CommandTooLong: `args` exceeds built in command line length limit on Windows. """ - return { - 'jpg': _get_cv2_param('IMWRITE_JPEG_QUALITY'), - 'png': _get_cv2_param('IMWRITE_PNG_COMPRESSION'), - 'webp': _get_cv2_param('IMWRITE_WEBP_QUALITY') - } + try: + return subprocess.call(args) + except OSError as err: + if os.name != "nt": + raise + exception_string = str(err) + # Error 206: The filename or extension is too long + # Error 87: The parameter is incorrect + to_match = ("206", "87") + if any([x in exception_string for x in to_match]): + raise CommandTooLong() from err + raise + + +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. + """ + # Try invoking ffmpeg with the current environment. + try: + subprocess.call(["ffmpeg", "-v", "quiet"]) + return "ffmpeg" + except OSError: + pass # Failed to invoke ffmpeg with current environment, try another possibility. + # Try invoking ffmpeg using the one from `imageio_ffmpeg` if available. + try: + from imageio_ffmpeg import get_ffmpeg_exe + + subprocess.call([get_ffmpeg_exe(), "-v", "quiet"]) + return get_ffmpeg_exe() + # Gracefully handle case where imageio_ffmpeg is not available. + except ModuleNotFoundError: + pass + # Handle case where path might be wrong/non-existent. + except OSError: + pass + # get_ffmpeg_exe may throw a RuntimeError if the executable is not available. + except RuntimeError: + pass + + return None + + +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: + return None + # If get_ffmpeg_path() returns a value, the path it returns should be invocable. + output = subprocess.check_output(args=[ffmpeg_path, "-version"], text=True) + output_split = output.split() + if len(output_split) >= 3 and output_split[1] == "version": + return output_split[2] + # If parsing the version fails, return the entire first line of output. + return output.splitlines()[0] + + +def get_mkvmerge_path() -> str | None: + """Get path to mkvmerge if available on the current system by checking PATH. Returns None if + mkvmerge couldn't be found.""" + try: + subprocess.call(["mkvmerge", "--quiet"]) + return "mkvmerge" + except OSError: + return None -## -## Python csv Module Wrapper (for StatsManager, and CliContext/list-scenes command) -## -def get_csv_reader(file_handle): - # type: (File) -> csv.reader - """ Returns a csv.reader object using the passed file handle. """ - return csv.reader(file_handle, lineterminator='\n') +def get_mkvmerge_version() -> str | None: + """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" + tool_name = "mkvmerge" + try: + output = subprocess.check_output(args=[tool_name, "--version"], text=True) + except FileNotFoundError: + # mkvmerge doesn't exist on the system + return None + output_split = output.split() + if len(output_split) >= 1 and output_split[0] == tool_name: + return " ".join(output_split[1:]) + # If parsing the version fails, return the entire first line of output. + return output.splitlines()[0] -def get_csv_writer(file_handle): - # type: (File) -> csv.writer - """ Returns a csv.writer object using the passed file handle. """ - return csv.writer(file_handle, lineterminator='\n') +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. + """ + line_separator = "-" * 60 + not_found_str = "Not Installed" + out_lines = [] + + system_info = ( + ("OS", f"{platform.platform()}"), + ("Python", f"{platform.python_implementation()} {platform.python_version()}"), + ("Architecture", " + ".join(platform.architecture())), + ) + + # 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", "av"), + ("click", "click"), + ("opencv-python", None), + ("opencv-python-headless", "cv2"), + ("imageio", "imageio"), + ("imageio-ffmpeg", "imageio_ffmpeg"), + ("moviepy", "moviepy"), + ("numpy", "numpy"), + ("platformdirs", "platformdirs"), + ("tqdm", "tqdm"), + ) + 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_versions = ( + ("ffmpeg", get_ffmpeg_version() or not_found_str), + ("mkvmerge", get_mkvmerge_version() or 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) + + +class Template(string.Template): + """Template matcher used to replace instances of $TEMPLATES in filenames.""" + + idpattern = "[A-Z0-9_]+" + flags = re.ASCII diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 0a182bf1..fed33b97 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -1,108 +1,22 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect `scenedetect.scene_detector` Module - -This module implements the base SceneDetector class, from which all scene -detectors in the scenedetect.dectectors module are derived from. - -The SceneDetector class represents the interface which detection algorithms -are expected to provide in order to be compatible with PySceneDetect. -""" - -# pylint: disable=unused-argument, no-self-use - - -class SceneDetector(object): - """ Base class to inheret from when implementing a scene detection algorithm. - - Also see the implemented scene detectors in the scenedetect.detectors module - to get an idea of how a particular detector can be created. - """ - - stats_manager = None - """ Optional :py:class:`StatsManager ` to - use for caching frame metrics to and from.""" - - _metric_keys = [] - """ List of frame metric keys to be registered with the :py:attr:`stats_manager`, - if available. """ - - cli_name = 'detect-none' - """ Name of detector to use in command-line interface description. """ - - def is_processing_required(self, frame_num): - # type: (int) -> bool - """ Is Processing Required: Test if all calculations for a given frame are already done. - - Returns: - bool: 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). - """ - return not self._metric_keys or not ( - self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num, self._metric_keys)) - - - def get_metrics(self): - # type: () -> List[str] - """ Get Metrics: Get a list of all metric names/keys used by the detector. - - Returns: - List[str]: A list of strings of frame metric key names that will be used by - the detector when a StatsManager is passed to process_frame. - """ - return self._metric_keys - - - def process_frame(self, frame_num, frame_img): - # type: (int, numpy.ndarray) -> Tuple[bool, Union[None, List[int]] - """ Process Frame: Computes/stores metrics and detects any scene changes. - - Prototype method, no actual detection. - - Returns: - List[int]: List of frame numbers of cuts to be added to the cutting list. - """ - return [] - - - def post_process(self, frame_num): - # type: (int) -> List[int] - """ Post Process: Performs any processing after the last frame has been read. +"""DEPRECATED""" - Prototype method, no actual detection. +import warnings - Returns: - List[int]: List of frame numbers of cuts to be added to the cutting list. - """ - return [] +warnings.warn( + "The `scene_detector` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) +from scenedetect.detector import * # noqa: E402, F403 diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 34fee38f..5cb46189 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -1,401 +1,737 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. +# 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_manager`` Module + +This module implements :class:`SceneManager`, coordinates running a +:mod:`SceneDetector ` over the frames of a video +(:mod:`VideoStream `). Video decoding is done in a separate thread to +improve performance. + +=============================================================== +Usage +=============================================================== + +The following example shows basic usage of a :class:`SceneManager`: + +.. code:: python + + from scenedetect import open_video, SceneManager, ContentDetector + video = open_video(video_path) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + # Detect all scenes in video from current position to end. + scene_manager.detect_scenes(video) + # `get_scene_list` returns a list of start/end timecode pairs + # for each scene that was found. + scenes = scene_manager.get_scene_list() + +An optional callback can also be invoked on each detected scene, for example: + +.. code:: python + + from scenedetect import open_video, SceneManager, ContentDetector + + # 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) + + video = open_video(test_video_file) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video=video, callback=on_new_scene) + +To use a `SceneManager` with a webcam/device or existing `cv2.VideoCapture` device, use the +:class:`VideoCaptureAdapter ` instead of +`open_video`. + +======================================================================= +Storing Per-Frame Statistics +======================================================================= -""" PySceneDetect `scenedetect.scene_manager` Module - -This module implements the :py:class:`SceneManager` object, which is used to coordinate -SceneDetectors and frame sources (:py:class:`VideoManager ` -or ``cv2.VideoCapture``), creating a cut list (see :py:meth:`SceneManager.get_cut_list`) -of all changes in scene, which is used to generate a final list of scenes -(see :py:meth:`SceneManager.get_scene_list`) which contains pairs of start/end -:py:class:`FrameTimecode ` -objects at each scene boundaries. - -The :py:class:`FrameTimecode ` objects and `tuples` -thereof returned by :py:meth:`get_cut_list ` and -:py:meth:`get_scene_list `, respectively, can be sorted if for -some reason the scene (or cut) list becomes unsorted. The :py:class:`SceneManager` also -facilitates passing a :py:class:`scenedetect.stats_manager.StatsManager`, -if any is defined, to the associated :py:class:`scenedetect.scene_detector.SceneDetector` -objects for caching of frame metrics. - -This speeds up subsequent calls to the :py:meth:`SceneManager.detect_scenes` method -that process the same frames with the same detection algorithm, even if different -threshold values (or other algorithm options) are used. +`SceneManager` can use an optional +:class:`StatsManager ` to save frame statistics to disk: + +.. code:: python + + from scenedetect import open_video, ContentDetector, SceneManager, StatsManager + video = open_video(test_video_file) + scene_manager = SceneManager(stats_manager=StatsManager()) + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video=video) + scene_list = scene_manager.get_scene_list() + print_scenes(scene_list=scene_list) + # Save per-frame statistics to disk. + scene_manager.stats_manager.save_to_csv(csv_file=STATS_FILE_PATH) + +The statsfile can be used to find a better threshold for certain inputs, or perform statistical +analysis of the video. """ -# Standard Library Imports -from __future__ import print_function -import math +import logging +import queue +import sys +import threading +import typing as ty +import warnings -# Third-Party Library Imports import cv2 +import numpy as np + +from scenedetect.common import ( + CropRegion, + CutList, + FrameTimecode, + Interpolation, + SceneList, + TimecodeLike, +) +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 -# PySceneDetect Library Imports -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.platform import get_csv_writer -from scenedetect.stats_manager import FrameMetricRegistered +logger = logging.getLogger("pyscenedetect") +# 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. +DEFAULT_MIN_WIDTH: int = 256 +"""The default minimum width a frame will be downscaled to when calculating a downscale factor.""" -## -## SceneManager Helper Functions -## +MAX_FRAME_QUEUE_LENGTH: int = 4 +"""Maximum number of decoded frames which can be buffered while waiting to be processed.""" + +MAX_FRAME_SIZE_ERRORS: int = 16 +"""Maximum number of frame size error messages that can be logged.""" + +PROGRESS_BAR_DESCRIPTION = " Detected: %d | Progress" +"""Template to use for progress bar.""" + + +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). + + The resulting effective width of the video will be between frame_width and 1.5 * frame_width + pixels (e.g. if frame_width is 200, the range of effective widths will be between 200 and 300). + + Arguments: + frame_width: Actual width of the video frame in pixels. + effective_width: Desired minimum width in pixels. + + Returns: + int: The default downscale factor to use to achieve at least the target effective_width. + """ + assert frame_width > 0 and effective_width > 0 + if frame_width < effective_width: + return 1 + 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. -def get_scenes_from_cuts(cut_list, base_timecode, num_frames, start_frame=0): - # type: List[FrameTimecode], FrameTimecode, Union[int, FrameTimecode], - # Optional[Union[int, FrameTimecode]] -> List[Tuple[FrameTimecode, FrameTimecode]] - """ Returns a list of tuples of start/end FrameTimecodes for each scene based on a + 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: 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. - This function is called when using the :py:meth:`SceneManager.get_scene_list` method. - The scene list is generated from a cutting list (:py:meth:`SceneManager.get_cut_list`), + This function is called when using the :meth:`SceneManager.get_scene_list` method. + The scene list is generated from a cutting list (:meth:`SceneManager.get_cut_list`), noting that each scene is contiguous, starting from the first to last frame of the input. - + If `cut_list` is empty, the resulting scene will span from `start_pos` to `end_pos`. Arguments: - cut_list (List[FrameTimecode]): List of FrameTimecode objects where scene cuts/breaks occur. - base_timecode (FrameTimecode): The base_timecode of which all FrameTimecodes in the cut_list - are based on. - num_frames (int or FrameTimecode): The number of frames, or FrameTimecode representing - duration, of the video that was processed (used to generate last scene's end time). - start_frame (int or FrameTimecode): The start frame or FrameTimecode of the cut list. - Used to generate the first scene's start time. + cut_list: List of FrameTimecode objects where scene cuts/breaks occur. + 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. 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. """ + # Scene list, where scenes are tuples of (Start FrameTimecode, End FrameTimecode). scene_list = [] if not cut_list: - scene_list.append((base_timecode + start_frame, base_timecode + num_frames)) + scene_list.append((start_pos, end_pos)) return scene_list # Initialize last_cut to the first frame we processed,as it will be # the start timecode for the first scene in the list. - last_cut = base_timecode + start_frame + last_cut = start_pos for cut in cut_list: scene_list.append((last_cut, cut)) last_cut = cut # Last scene is from last cut to end of video. - scene_list.append((last_cut, base_timecode + num_frames)) + scene_list.append((last_cut, end_pos)) return scene_list -def write_scene_list(output_csv_file, scene_list, cut_list=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. - 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. - """ - # type: (File, List[Tuple[FrameTimecode, FrameTimecode]], Optional[List[FrameTimecode]]) -> None - csv_writer = get_csv_writer(output_csv_file) - # Output Timecode 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(), 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()]) - - ## ## SceneManager Class Implementation ## -class SceneManager(object): - """ The SceneManager facilitates detection of scenes via the :py:meth:`detect_scenes` method, - given a video source (:py:class:`VideoManager ` - or cv2.VideoCapture), and SceneDetector algorithms added via the :py:meth:`add_detector` method. - Can also optionally take a StatsManager instance during construction to cache intermediate - scene detection calculations, making subsequent calls to :py:meth:`detect_scenes` much faster, - allowing the cached values to be saved/loaded to/from disk, and also manually determining - the optimal threshold values or other options for various detection algorithms. +class SceneManager: + """The SceneManager facilitates detection of scenes (:meth:`detect_scenes`) on a video + (:class:`VideoStream `) using a detector + (:meth:`add_detector`). Video decoding is done in parallel in a background thread. """ - def __init__(self, stats_manager=None): - # type: (Optional[StatsManager]) - self._cutting_list = [] - self._detector_list = [] - self._stats_manager = stats_manager - self._num_frames = 0 - self._start_frame = 0 - - - def add_detector(self, detector): - # type: (SceneDetector) -> None - """ Adds/registers a SceneDetector (e.g. ContentDetector, ThresholdDetector) to + def __init__( + self, + 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: 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. 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 = None + # Position of video on the last frame processed by detect_scenes. + self._last_pos: FrameTimecode | None = None + # Size of the decoded frames. + self._frame_size: tuple[int, int] | None = None + self._frame_size_errors: int = 0 + 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 + # Set by decode thread when an exception occurs. + self._exception_info = None + self._stop = threading.Event() + + self._frame_buffer: list[tuple[FrameTimecode, np.ndarray]] = [] + self._frame_buffer_size = 0 + self._crop = None + + @property + def interpolation(self) -> Interpolation: + """Interpolation method to use when downscaling frames. Must be one of cv2.INTER_*.""" + return self._interpolation + + @interpolation.setter + def interpolation(self, value: Interpolation): + self._interpolation = value + + @property + 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 + indicates no scaling. Will be ignored if auto_downscale=True.""" + return self._downscale + + @downscale.setter + def downscale(self, value: int): + """Set to 1 for no downscaling, 2 for 2x downscaling, 3 for 3x, etc...""" + if value < 1: + raise ValueError("Downscale factor must be a positive integer >= 1!") + if self.auto_downscale: + logger.warning("Downscale factor will be ignored because auto_downscale=True!") + if value is not None and not isinstance(value, int): + logger.warning("Downscale factor will be truncated to integer!") + value = int(value) + self._downscale = value + + @property + def auto_downscale(self) -> bool: + """If set to True, will automatically downscale based on video frame size. + + Overrides `downscale` if set.""" + return self._auto_downscale + + @auto_downscale.setter + def auto_downscale(self, value: bool): + self._auto_downscale = value + + def add_detector(self, detector: SceneDetector) -> None: + """Add/register a SceneDetector (e.g. ContentDetector, ThresholdDetector) to run when detect_scenes is called. The SceneManager owns the detector object, so a temporary may be passed. Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ + detector.stats_manager = self._stats_manager - self._detector_list.append(detector) if self._stats_manager is not None: - # Allow multiple detection algorithms of the same type to be added - # by suppressing any FrameMetricRegistered exceptions due to attempts - # to re-register the same frame metric keys. - try: - self._stats_manager.register_metrics(detector.get_metrics()) - except FrameMetricRegistered: - pass - - def get_num_detectors(self): - # type: () -> int - """ Gets number of registered scene detectors added via add_detector. """ - return len(self._detector_list) + self._stats_manager.register_metrics(detector.get_metrics()) + self._detector_list.append(detector) - def clear(self): - # type: () -> None - """ Clears all cuts/scenes and resets the SceneManager's position. + self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) - Any statistics generated are still saved in the StatsManager object - passed to the SceneManager's constructor, and thus, subsequent - calls to detect_scenes, using the same frame source reset at the - initial time (if it is a VideoManager, use the reset() method), - will use the cached frame metrics that were computed and saved - in the previous call to detect_scenes. + def get_num_detectors(self) -> int: + """Get number of registered scene detectors added via add_detector.""" + return len(self._detector_list) + + def clear(self) -> None: + """Clear all cuts/scenes and resets the SceneManager's position. + + Any statistics generated are still saved in the StatsManager object passed to the + SceneManager's constructor, and thus, subsequent calls to detect_scenes, using the same + frame source seeked back to the original time (or beginning of the video) will use the + cached frame metrics that were computed and saved in the previous call to detect_scenes. """ self._cutting_list.clear() - self._num_frames = 0 - self._start_frame = 0 - + self._last_pos = None + self._start_pos = None + self._frame_size = None + self.clear_detectors() - def clear_detectors(self): - # type: () -> None - """ Removes all scene detectors added to the SceneManager via add_detector(). """ + def clear_detectors(self) -> None: + """Remove all scene detectors added to the SceneManager via add_detector().""" self._detector_list.clear() + def get_scene_list(self, start_in_scene: bool = False) -> SceneList: + """Return a list of tuples of start/end FrameTimecodes for each detected scene. - def get_scene_list(self, base_timecode): - # type: (FrameTimecode) -> List[Tuple[FrameTimecode, FrameTimecode]] - """ Returns a list of tuples of start/end FrameTimecodes for each scene. - - The scene list is generated by calling :py:func:`get_scenes_from_cuts` on the cutting - list from :py:meth:`get_cut_list`, noting that each scene is contiguous, starting from - the first and ending at the last frame of the input. + Arguments: + 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). + When detecting fades with `ThresholdDetector`, the beginning portion of the video + will always be included until the first fade-out event is detected. 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 detected scene in the video begins and ends. """ - return get_scenes_from_cuts( - self.get_cut_list(base_timecode), base_timecode, - self._num_frames, self._start_frame) - - - def get_cut_list(self, base_timecode): - # type: (FrameTimecode) -> List[FrameTimecode] - """ Returns a list of FrameTimecodes of the detected scene changes/cuts. - - Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing - the point in the input video(s) where a new scene was detected, and thus the frame - where the input should be cut/split. The cutting list, in turn, is used to generate - the scene list, noting that each scene is contiguous starting from the first frame - and ending at the last frame detected. - - Returns: - List of FrameTimecode objects denoting the points in time where a scene change - was detected in the input video(s), which can also be passed to external tools - for automated splitting of the input into individual scenes. - """ - - return [FrameTimecode(cut, base_timecode) - for cut in self._get_cutting_list()] - - - def _get_cutting_list(self): - # type: () -> list - """ Returns a sorted list of unique frame numbers of any detected scene cuts. """ - # We remove duplicates here by creating a set then back to a list and sort it. - return sorted(list(set(self._cutting_list))) - - - def _add_cut(self, frame_num): - # type: (int) -> None - # Adds a cut to the cutting list. - self._cutting_list.append(frame_num) - - - def _add_cuts(self, cut_list): - # type: (List[int]) -> None - # Adds a list of cuts to the cutting list. - self._cutting_list += cut_list - - - def _process_frame(self, frame_num, frame_im): - # type(int, numpy.ndarray) -> None - """ Adds any cuts detected with the current frame to the cutting list. """ + 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( + cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1 + ) + # If we didn't actually detect any cuts, make sure the resulting scene_list is empty + # unless start_in_scene is True. + if not cut_list and not start_in_scene: + scene_list = [] + return sorted(scene_list) + + 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 [] + # Ensure all cuts are unique by using a set to remove all duplicates. + return [cut for cut in sorted(set(self._cutting_list))] + + def _process_frame( + self, + position: FrameTimecode, + frame_im: np.ndarray, + 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(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: - self._add_cuts(detector.process_frame(frame_num, frame_im)) - - - def _is_processing_required(self, frame_num): - # type(int) -> bool - """ Is Processing Required: Returns True if frame metrics not in StatsManager, - False otherwise. - """ - return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) - - - def _post_process(self, frame_num): - # type(int, numpy.ndarray) -> None - """ Adds any remaining cuts to the cutting list after processing the last frame. """ + cuts = detector.process_frame(position, frame_im) + self._cutting_list += cuts + new_cuts = bool(cuts) + if callback: + for cut in cuts: + for position, frame in self._frame_buffer: + if cut == position: + callback(frame, position) + return new_cuts + + 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._add_cuts(detector.post_process(frame_num)) - - - def detect_scenes(self, frame_source, end_time=None, frame_skip=0, - show_progress=True): - # type: (VideoManager, Union[int, FrameTimecode], - # Optional[Union[int, FrameTimecode]], Optional[bool]) -> int - """ Perform scene detection on the given frame_source using the added SceneDetectors. - - Blocks until all frames in the frame_source have been processed. Results can - be obtained by calling either the get_scene_list() or get_cut_list() methods. + self._cutting_list += detector.post_process(timecode) + + def stop(self) -> None: + """Stop the current :meth:`detect_scenes` call, if any. Thread-safe.""" + self._stop.set() + + def detect_scenes( + self, + video: VideoStream | None = None, + duration: TimecodeLike | None = None, + end_time: TimecodeLike | None = None, + frame_skip: int = 0, + show_progress: bool = False, + 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 + :meth:`get_cut_list`. + + Video decoding is performed in a background thread to allow scene detection and frame + decoding to happen in parallel. Detection will continue until no more frames are left, + the specified duration or end time has been reached, or :meth:`stop` was called. Arguments: - frame_source (scenedetect.video_manager.VideoManager or cv2.VideoCapture): - A source of frames to process (using frame_source.read() as in VideoCapture). - VideoManager is preferred as it allows concatenation of multiple videos - as well as seeking, by defining start time and end time/duration. - end_time (int or FrameTimecode): Maximum number of frames to detect - (set to None to detect all available frames). Only needed for OpenCV - VideoCapture objects; for VideoManager objects, use set_duration() instead. - frame_skip (int): Not recommended except for extremely high framerate videos. + video: VideoStream obtained from either `scenedetect.open_video`, or by creating + one directly (e.g. `scenedetect.backends.opencv.VideoStreamCv2`). + duration: Amount of time to detect from current video position. Cannot be + specified if `end_time` is set. + end_time: Time to stop processing at. Cannot be specified if `duration` is set. + frame_skip: Not recommended except for extremely high framerate videos. Number of frames to skip (i.e. process every 1 in N+1 frames, where N is frame_skip, processing only 1/N+1 percent of the video, speeding up the detection time at the expense of accuracy). `frame_skip` **must** be 0 (the default) when using a StatsManager. - show_progress (bool): If True, and the ``tqdm`` module is available, displays + show_progress: If True, and the ``tqdm`` module is available, displays a progress bar with the progress, framerate, and expected time to 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.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: + raise TypeError("detect_scenes() missing 1 required positional argument: 'video'") + if frame_skip > 0 and self.stats_manager is not None: + raise ValueError("frame_skip must be 0 when using a StatsManager.") + if duration is not None and end_time is not None: + raise ValueError("duration and end_time cannot be set at the same time!") + # TODO: These checks should be handled by the FrameTimecode constructor. + if duration is not None and isinstance(duration, (int, float)) and duration < 0: + raise ValueError("duration must be greater than or equal to 0!") + 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. + if self._stats_manager is not None: + self._stats_manager._base_timecode = self._base_timecode - if frame_skip > 0 and self._stats_manager is not None: - raise ValueError('frame_skip must be 0 when using a StatsManager.') - - start_frame = 0 - curr_frame = 0 - end_frame = None - - total_frames = math.trunc(frame_source.get(cv2.CAP_PROP_FRAME_COUNT)) - - start_time = frame_source.get(cv2.CAP_PROP_POS_FRAMES) - if isinstance(start_time, FrameTimecode): - start_frame = start_time.get_frames() - elif start_time is not None: - start_frame = int(start_time) - self._start_frame = start_frame - - curr_frame = start_frame - - if isinstance(end_time, FrameTimecode): - end_frame = end_time.get_frames() - elif end_time is not None: - end_frame = int(end_time) - - if end_frame is not None: - total_frames = end_frame - - if start_frame is not None and not isinstance(start_time, FrameTimecode): - total_frames -= start_frame + start_frame_num: int = video.frame_number + if end_time is not None: + end_time = self._base_timecode + end_time + elif duration is not None: + end_time = (self._base_timecode + duration) + start_frame_num - if total_frames < 0: - total_frames = 0 + total_frames = 0 + if video.duration is not None: + if end_time is not None and end_time < video.duration: + total_frames = end_time - start_frame_num + else: + total_frames = video.duration.frame_num - start_frame_num progress_bar = None - if tqdm and show_progress: + if show_progress: progress_bar = tqdm( - total=total_frames, unit='frames') + total=int(total_frames), + unit="frames", + desc=PROGRESS_BAR_DESCRIPTION % 0, + dynamic_ncols=True, + ) + + frame_queue = queue.Queue(MAX_FRAME_QUEUE_LENGTH) + self._stop.clear() + decode_thread = threading.Thread( + target=SceneManager._decode_thread, + args=(self, video, frame_skip, downscale_factor, end_time, frame_queue), + daemon=True, + ) + decode_thread.start() + frame_im = None + + logger.info("Detecting scenes...") try: - - while True: - if end_frame is not None and curr_frame >= end_frame: + while not self._stop.is_set(): + next_frame, position = frame_queue.get() + if next_frame is None and position is None: break - # We don't compensate for frame_skip here as the frame_skip option - # is not allowed when using a StatsManager - thus, processing is - # *always* required for *all* frames when frame_skip > 0. - if (self._is_processing_required(self._num_frames + start_frame) - or self._is_processing_required(self._num_frames + start_frame + 1)): - ret_val, frame_im = frame_source.read() - else: - ret_val = frame_source.grab() - frame_im = None - - if not ret_val: + 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: + 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: + 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) + + return video.frame_number - start_frame_num + + def _decode_thread( + self, + video: VideoStream, + frame_skip: int, + downscale_factor: float, + end_time: FrameTimecode, + out_queue: queue.Queue, + ): + try: + while not self._stop.is_set(): + frame_im = None + # 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). + frame_im = video.read() + if frame_im is False: break - self._process_frame(self._num_frames + start_frame, frame_im) - - curr_frame += 1 - self._num_frames += 1 - if progress_bar: - progress_bar.update(1) + 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." + ) + 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: + self._start_pos = video.position + + out_queue.put((frame_im, video.position)) if frame_skip > 0: for _ in range(frame_skip): - if not frame_source.grab(): + if not video.read(decode=False): break - curr_frame += 1 - self._num_frames += 1 - if progress_bar: - progress_bar.update(1) - - self._post_process(curr_frame) + # End time includes the presentation time of the frame, but the `position` + # property of a VideoStream references the beginning of the frame in time. + if end_time is not None and not (video.position + 1) < end_time: + break - num_frames = curr_frame - start_frame + # If *any* exceptions occur, we re-raise them in the main thread so that the caller of + # detect_scenes can handle it. + except KeyboardInterrupt: + logger.debug("Received KeyboardInterrupt.") + self._stop.set() + except BaseException: + logger.critical("Fatal error: Exception raised in decode thread.") + self._exception_info = sys.exc_info() + self._stop.set() finally: + # Handle case where start position was never set if we did not decode any frames. + if self._start_pos is None: + self._start_pos = video.position + # Make sure main thread stops processing loop. + out_queue.put((None, None)) + + # + # Deprecated Methods + # + + def get_cut_list( + self, + show_warning: bool = True, + ) -> CutList: + """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. - if progress_bar: - progress_bar.close() + Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing + the point in the input video where a new scene was detected, and thus the frame + where the input should be cut/split. The cutting list, in turn, is used to generate + the scene list, noting that each scene is contiguous starting from the first frame + and ending at the last frame detected. - return num_frames + Arguments: + 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. + Returns: + List of FrameTimecode objects denoting the points in time where a scene change + was detected in the input video, which can also be passed to external tools + for automated splitting of the input into individual scenes. + + """ + if show_warning: + warnings.warn( + "get_cut_list() is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + return self._get_cutting_list() diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 29ab6323..dc415f3c 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -1,139 +1,90 @@ -# -*- coding: utf-8 -*- # -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file or visit one of the following pages for details: -# - http://www.bcastell.com/projects/pyscenedetect/ -# - https://github.com/Breakthrough/PySceneDetect/ -# -# This software uses the Numpy, OpenCV, click, tqdm, and pytest libraries. -# See the included LICENSE files or one of the above URLs for more information. +# included LICENSE file, or visit one of the above pages for details. # -# 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. -# - -""" PySceneDetect `scenedetect.stats_manager` Module +"""``scenedetect.stats_manager`` Module -This module contains the :py:class:`StatsManager` class, which provides a key-value store -for each :py:class:`SceneDetector ` to read/write -the metrics calculated for each frame. The :py:class:`StatsManager` must be registered to a -:py:class:`SceneManager ` by passing it to the -:py:class:`SceneManager constructor ` as the -`stats_manager` argument. +This module contains the :class:`StatsManager` class, which provides a key-value store for each +:class:`SceneDetector ` to write the metrics calculated +for each frame. The :class:`StatsManager` must be registered to a +:class:`SceneManager ` upon construction. -The entire :py:class:`StatsManager` can be :py:meth:`saved to ` -and :py:meth:`loaded from ` a human-readable CSV -file, also allowing both precise determination of the threshold or other optimal values -for video files. See the :py:meth:`save_to_csv() ` and -:py:meth:`load_from_csv() ` methods for more information. - -The :py:class:`StatsManager` can also be used to cache the calculation results of the scene -detectors being used, speeding up subsequent scene detection runs using the same pair of -:py:class:`SceneManager`/:py:class:`StatsManager` objects. +The entire :class:`StatsManager` can be :meth:`saved to ` a +human-readable CSV file, allowing for precise determination of the ideal threshold (or other +detection parameters) for the given input. """ -# Standard Library Imports -from __future__ import print_function -import logging - -# PySceneDetect Library Imports -from scenedetect.frame_timecode import MINIMUM_FRAMES_PER_SECOND_FLOAT -from scenedetect.platform import get_csv_reader -from scenedetect.platform import get_csv_writer +import csv +import os +import os.path +import typing as ty +from logging import getLogger +from pathlib import Path -# pylint: disable=useless-super-delegation +from scenedetect.common import FrameTimecode +from scenedetect.platform import StrPath +logger = getLogger("pyscenedetect") ## ## StatsManager CSV File Column Names (Header Row) ## -COLUMN_NAME_FPS = "Frame Rate:" COLUMN_NAME_FRAME_NUMBER = "Frame Number" -COLUMN_NAME_TIMECODE = "Timecode" +"""Name of column containing frame numbers in the statsfile CSV.""" +COLUMN_NAME_TIMECODE = "Timecode" +"""Name of column containing timecodes in the statsfile CSV.""" ## ## StatsManager Exceptions ## + class FrameMetricRegistered(Exception): - """ Raised when attempting to register a frame metric key which has - already been registered. """ - def __init__(self, metric_key, message="Attempted to re-register frame metric key."): - # type: (str, str) - # Pass message string to base Exception class. - super(FrameMetricRegistered, self).__init__(message) - self.metric_key = metric_key + """[DEPRECATED - DO NOT USE] No longer used. + + :meta private: + """ + + pass class FrameMetricNotRegistered(Exception): - """ Raised when attempting to call get_metrics(...)/set_metrics(...) with a - frame metric that does not exist, or has not been registered. """ - def __init__(self, metric_key, message= - "Attempted to get/set frame metrics for unregistered metric key."): - # type: (str, str) - # Pass message string to base Exception class. - super(FrameMetricNotRegistered, self).__init__(message) - self.metric_key = metric_key + """[DEPRECATED - DO NOT USE] No longer used. + :meta private: + """ -class StatsFileCorrupt(Exception): - """ Raised when frame metrics/stats could not be loaded from a provided CSV file. """ - def __init__(self, message= - "Could not load frame metric data data from passed CSV file."): - # type: (str, str) - # Pass message string to base Exception class. - super(StatsFileCorrupt, self).__init__(message) - - -class StatsFileFramerateMismatch(Exception): - """ Raised when attempting to load a CSV file with a framerate that differs from - the current base timecode / VideoManager. """ - def __init__(self, base_timecode_fps, stats_file_fps, message= - "Framerate differs between stats file and base timecode."): - # type: (str, str) - # Pass message string to base Exception class. - super(StatsFileFramerateMismatch, self).__init__(message) - self.base_timecode_fps = base_timecode_fps - self.stats_file_fps = stats_file_fps - - -class NoMetricsRegistered(Exception): - """ Raised when attempting to save a CSV file via save_to_csv(...) without any - frame metrics having been registered (i.e. no SceneDetector objects were added - to the owning SceneManager object, if any). """ pass -class NoMetricsSet(Exception): - """ Raised if no frame metrics have been set via set_metrics(...) when attempting - to save the stats to a CSV file via save_to_csv(...). This may also indicate that - detect_scenes(...) was not called on the owning SceneManager object, if any. """ - pass +class StatsFileCorrupt(Exception): + """Raised when frame metrics/stats could not be loaded from a provided CSV file.""" + + def __init__( + self, message: str = "Could not load frame metric data data from passed CSV file." + ): + super().__init__(message) ## ## StatsManager Class Implementation ## -class StatsManager(object): - """ Provides a key-value store for frame metrics/calculations which can be used - as a cache to speed up subsequent calls to a SceneManager's detect_scenes(...) - method. The statistics can be saved to a CSV file, and loaded from disk. + +# TODO(v1.0): Relax restriction on metric types only being float or int when loading from disk +# is fully deprecated. +class StatsManager: + """Provides a key-value store for frame metrics/calculations which can be used + for two-pass detection algorithms, as well as saving stats to a CSV file. Analyzing a statistics CSV file is also very useful for finding the optimal algorithm parameters for certain detection methods. Additionally, the data @@ -141,80 +92,68 @@ class StatsManager(object): metric of interest for a series of frames by iteratively calling get_metrics(), after having called the detect_scenes(...) method on the SceneManager object which owns the given StatsManager instance. - """ - - def __init__(self): - # type: () - # 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() # Dict[FrameTimecode, Dict[str, float]] - self._registered_metrics = set() # Set of frame metric keys. - self._loaded_metrics = set() # Metric keys loaded from stats file. - self._metrics_updated = False # Flag indicating if metrics require saving. - - - def register_metrics(self, metric_keys): - # type: (List[str]) -> bool - """ Register Metrics - - Register a list of metric keys that will be used by the detector. - Used to ensure that multiple detector keys don't overlap. - - Raises: - FrameMetricRegistered: A particular metric_key has already been registered/added - to the StatsManager. Only if the StatsManager is being used for read-only - access (i.e. all frames in the video have already been processed for the given - metric_key in the exception) is this behavior desirable. - """ - for metric_key in metric_keys: - if metric_key not in self._registered_metrics: - self._registered_metrics.add(metric_key) - else: - raise FrameMetricRegistered(metric_key) + Only metrics consisting of `float` or `int` should be used currently. + """ - def get_metrics(self, frame_number, metric_keys): - # type: (int, List[str]) -> List[Union[None, int, float, str]] - """ Get Metrics: Returns the requested statistics/metrics for a given frame. + def __init__(self, base_timecode: int | FrameTimecode | None = None): + """Initialize a new StatsManager. Arguments: - frame_number (int): Frame number to retrieve metrics for. - metric_keys (List[str]): A list of metric keys to look up. + base_timecode: Timecode associated with this object. Must not be None (default value + will be removed in a future release). + """ + # 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: int | FrameTimecode | None = ( + base_timecode # Used for timing calculations. + ) + + @property + def metric_keys(self) -> ty.Iterable[str]: + return self._metric_keys + + 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(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, metric_kv_dict): - # type: (int, Dict[str, Union[None, int, float, str]]) -> None - """ Set Metrics: Sets the provided statistics/metrics for a given frame. + 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 (int): Frame number to retrieve metrics for. - metric_kv_dict (Dict[str, metric]): 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, metric_keys): - # type: (int, List[str]) -> bool - """ Metrics Exist: Checks if the given metrics/stats exist for the given frame. + 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): - # type: () -> bool - """ Is Save Required: Checks if the stats have been updated since loading. + def is_save_required(self) -> bool: + """Is Save Required: Checks if the stats have been updated since loading. Returns: bool: True if there are frame metrics/statistics not yet written to disk, @@ -222,141 +161,154 @@ def is_save_required(self): """ return self._metrics_updated - - def save_to_csv(self, csv_file, base_timecode, force_save=True): - # type: (File [w], FrameTimecode, bool) -> None - """ Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. + def save_to_csv( + self, + 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')). - base_timecode: The base_timecode obtained from the frame source VideoManager. - If using an OpenCV VideoCapture, create one using the video framerate by - setting base_timecode=FrameTimecode(0, fps=video_framerate). - force_save: If True, forcably writes metrics out even if there are no - registered metrics or frame statistics. If False, a NoMetricsRegistered - will be thrown if there are no registered metrics, and a NoMetricsSet - exception will be thrown if is_save_required() returns False. + csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str. + force_save: If True, writes metrics out even if an update is not required. Raises: - NoMetricsRegistered: No frame metrics have been registered to save, - nor is there any frame data to save. - NoMetricsSet: No frame metrics have been entered/updated, thus there - is no frame data to save. + OSError: If `path` cannot be opened or a write failure occurs. """ - csv_writer = get_csv_writer(csv_file) - # Ensure we need to write to the file, and that we have data to do so with. - if ((self.is_save_required() or force_save) and - self._registered_metrics and self._frame_metrics): - # Header rows. - metric_keys = sorted(list(self._registered_metrics.union(self._loaded_metrics))) - csv_writer.writerow([COLUMN_NAME_FPS, '%.10f' % base_timecode.get_framerate()]) + 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, 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]) + frame_keys = sorted(self._frame_metrics.keys()) + logger.info("Writing %d frames to CSV...", len(frame_keys)) + for frame_key in frame_keys: + # `frame_key` may be a bare `int` if the deprecated `load_from_csv` populated the dict. + # Skip such rows since we cannot recover a timecode without a base framerate. + if not isinstance(frame_key, FrameTimecode): + continue csv_writer.writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) - frame_keys = sorted(self._frame_metrics.keys()) - print("Writing %d frames to CSV..." % len(frame_keys)) - for frame_key in frame_keys: - frame_timecode = base_timecode + frame_key - csv_writer.writerow( - [frame_timecode.get_frames(), frame_timecode.get_timecode()] + - [str(metric) for metric in self.get_metrics(frame_key, metric_keys)]) - else: - if not self._registered_metrics: - raise NoMetricsRegistered() - if not self._frame_metrics: - raise NoMetricsSet() - - - def load_from_csv(self, csv_file, base_timecode=None, reset_save_required=True): - # type: (File [r], FrameTimecode, Optional[bool] -> int - """ Load From CSV: Loads all metrics stored in a CSV file into the StatsManager instance. + [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: + """Check that the given CSV row is a valid header for a statsfile. + + Arguments: + row: A row decoded from the CSV reader. + + Returns: + True if `row` is a valid statsfile header, False otherwise. + """ + if not row or not len(row) >= 2: + return False + 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: 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 + future release after becoming a no-op. Arguments: - csv_file: A file handle opened in read mode (e.g. open('...', 'r')). - base_timecode: The base_timecode obtained from the frame source VideoManager. - If using an OpenCV VideoCapture, create one using the video framerate by - setting base_timecode=FrameTimecode(0, fps=video_framerate). - If base_timecode is not set (i.e. is None), the framerate is not validated. - reset_save_required: If True, clears the flag indicating that a save is required. + csv_file: A file handle opened in read mode (e.g. open('...', 'r')) or a path as str. Returns: int or None: Number of frames/rows read from the CSV file, or None if the - input file was blank. + input file was blank or could not be found. Raises: StatsFileCorrupt: Stats file is corrupt and can't be loaded, or wrong file was specified. - StatsFileFramerateMismatch: Framerate does not match the loaded stats file, - indicating either the wrong video or wrong stats file was specified. + + :meta private: """ - csv_reader = get_csv_reader(csv_file) + # TODO: Make this an error, then make load_from_csv() a no-op, and finally, remove it. + logger.warning("load_from_csv() is deprecated and will be removed in a future release.") + + # 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, os.PathLike)): + if os.path.exists(csv_file): + with open(csv_file) as file: + return self.load_from_csv(csv_file=file) + # Path doesn't exist. + return None + + # If we get here, file is a valid file handle in read-only text mode. + csv_reader = csv.reader(csv_file, lineterminator="\n") num_cols = None num_metrics = None num_frames = None - # First row: Framerate, [video_framerate] + # First Row: Frame Num, Timecode, [metrics...] try: row = next(csv_reader) + # Backwards compatibility for previous versions of statsfile + # which included an additional header row. + if not self.valid_header(row): + row = next(csv_reader) except StopIteration: # If the file is blank or we couldn't decode anything, assume the file was empty. - return num_frames - # First Row (FPS = [...]) and ensure framerate equals base_timecode if set. - if not len(row) == 2 or not row[0] == COLUMN_NAME_FPS: - raise StatsFileCorrupt() - stats_file_framerate = float(row[1]) - if stats_file_framerate < MINIMUM_FRAMES_PER_SECOND_FLOAT: - raise StatsFileCorrupt("Invalid framerate detected in CSV stats file " - "(decoded FPS: %f)." % stats_file_framerate) - if base_timecode is not None and not base_timecode.equal_framerate(stats_file_framerate): - raise StatsFileFramerateMismatch(base_timecode.get_framerate(), stats_file_framerate) - # Second Row: Frame Num, Timecode, [metrics...] - try: - row = next(csv_reader) - except StopIteration: - raise StatsFileCorrupt("Header row(s) missing.") - if not row or not len(row) >= 2: - raise StatsFileCorrupt() - if row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE: + return None + if not self.valid_header(row): raise StatsFileCorrupt() num_cols = len(row) num_metrics = num_cols - 2 if not num_metrics > 0: - raise StatsFileCorrupt('No metrics defined in CSV file.') - metric_keys = row[2:] + raise StatsFileCorrupt("No metrics defined in CSV file.") + loaded_metrics = list(row[2:]) num_frames = 0 for row in csv_reader: metric_dict = {} if not len(row) == num_cols: - raise StatsFileCorrupt('Wrong number of columns detected in stats file row.') - for i, metric_str in enumerate(row[2:]): - if metric_str and metric_str != 'None': + raise StatsFileCorrupt("Wrong number of columns detected in stats file row.") + frame_number = int(row[0]) + # Switch from 1-based to 0-based frame numbers. + if frame_number > 0: + frame_number -= 1 + self.set_metrics(frame_number, metric_dict) + for i, metric in enumerate(row[2:]): + if metric and metric != "None": try: - metric_dict[metric_keys[i]] = float(metric_str) + self._set_metric(frame_number, loaded_metrics[i], float(metric)) except ValueError: - raise StatsFileCorrupt('Corrupted value in stats file: %s' % metric_str) - self.set_metrics(int(row[0]), metric_dict) + raise StatsFileCorrupt( + f"Corrupted value in stats file: {metric}" + ) from ValueError num_frames += 1 - logging.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) - if reset_save_required: - self._metrics_updated = False + self._metric_keys = self._metric_keys.union(set(loaded_metrics)) + logger.info("Loaded %d metrics for %d frames.", num_metrics, num_frames) + self._metrics_updated = False return num_frames + # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, frame_number, metric_key): - # type: (int, str) -> Union[None, int, float, str] - 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, metric_key, metric_value): + def _set_metric( + self, timecode: int | FrameTimecode, metric_key: str, metric_value: ty.Any + ) -> None: self._metrics_updated = True - # type: (int, str, Union[None, int, float, str]) -> None - if not frame_number in self._frame_metrics: - self._frame_metrics[frame_number] = dict() - self._frame_metrics[frame_number][metric_key] = metric_value - - - def _metric_exists(self, frame_number, metric_key): - # type: (int, List[str]) -> bool - return (frame_number in self._frame_metrics and - metric_key in self._frame_metrics[frame_number]) + if timecode not in self._frame_metrics: + self._frame_metrics[timecode] = dict() + self._frame_metrics[timecode][metric_key] = metric_value + def _metric_exists(self, timecode: 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 e247e1ed..00000000 --- a/scenedetect/video_manager.py +++ /dev/null @@ -1,773 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect `scenedetect.video_manager` Module - -This module contains the :py:class:`VideoManager` class, which provides a consistent -interface to reading videos, specific exceptions raised upon certain error -conditions, and some global helper functions to open/close multiple videos, -as well as validate their parameters. - -The :py:class:`VideoManager` can be constructed with a path to a video (or sequence of -videos) and a start and end time/duration, then passed to a `SceneManager` -object for performing scene detection analysis. If the start time is modified, -then it also needs to be reflected in the `SceneManager`. - -The :py:class:`VideoManager` class attempts to emulate some methods of the OpenCV -cv2.VideoCapture object, and can be used interchangably with one with -respect to a SceneManager object. -""" - -# There also used to be an asynchronous implementation in addition to the -# synchronous VideoManager, but the performance was poor. In the future, I may -# consider rewriting an asynchronous frame grabber in C++ and write a C-API to -# interface with the Python ctypes module. - B.C. - - -# Standard Library Imports -from __future__ import print_function -import os -import math - -# Third-Party Library Imports -import cv2 - -# PySceneDetect Library Imports -from scenedetect.platform import STRING_TYPE -import scenedetect.frame_timecode -from scenedetect.frame_timecode import FrameTimecode - - -## -## VideoManager Exceptions -## - -class VideoOpenFailure(Exception): - """ VideoOpenFailure: Raised when an OpenCV VideoCapture object fails to open (i.e. calling - the isOpened() method returns a non True value). """ - def __init__(self, file_list=None, message= - "OpenCV VideoCapture object failed to return True when calling isOpened()."): - # type: (Iterable[(str, str)], str) - # Pass message string to base Exception class. - super(VideoOpenFailure, self).__init__(message) - # list of (filename: str, filepath: str) - self.file_list = file_list - - -class VideoFramerateUnavailable(Exception): - """ VideoFramerateUnavailable: Raised when the framerate cannot be determined from the video, - and the framerate has not been overriden/forced in the VideoManager. """ - def __init__(self, file_name=None, file_path=None, message= - "OpenCV VideoCapture object failed to return framerate when calling " - "get(cv2.CAP_PROP_FPS)."): - # type: (str, str, str) - # Pass message string to base Exception class. - super(VideoFramerateUnavailable, self).__init__(message) - # Set other exception properties. - self.file_name = file_name - self.file_path = file_path - - -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) - # 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. """ - pass - - -class VideoDecoderNotStarted(RuntimeError): - """ VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that - must be called *after* start() has been called. """ - pass - - -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. """ - pass - - -## -## VideoManager Constants & Helper Functions -## - -DEFAULT_DOWNSCALE_FACTORS = { - 3200: 12, # ~4k - 2100: 8, # ~2k - 1700: 6, # ~1080p - 1200: 5, - 900: 4, # ~720p - 600: 3, - 400: 2 # ~480p -} -"""Dict[int, int]: The default downscale factor for a video of size W x H, -which enforces the constraint that W >= 200 to ensure an adequate amount -of pixels for scene detection while providing a speedup in processing. """ - - - -def compute_downscale_factor(frame_width): - # type: (int) -> int - """ Compute Downscale Factor: Returns the optimal default downscale factor based on - a video's resolution (specifically, the width parameter). - - Returns: - int: The defalt downscale factor to use with a video of frame_height x frame_width. - """ - for width in sorted(DEFAULT_DOWNSCALE_FACTORS, reverse=True): - if frame_width >= width: - return DEFAULT_DOWNSCALE_FACTORS[width] - return 1 - - -def get_video_name(video_file): - # type: (str) -> Tuple[str, str] - """ Get Video Name: Returns a string representing the video file/device name. - - Returns: - str: Video file name or device ID. In the case of a video, only the file - name is returned, not the whole path. For a device, the string format - is 'Device 123', where 123 is the integer ID of the capture device. - """ - 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): - # type: (List[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, framerate=None, validate_parameters=True): - # type: (Iterable[str], float, bool) -> Tuple[List[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 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. - A ValueError will be raised if the list does not conform to the above. - framerate (float, optional): 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. - VideoFramerateUnavailable: 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, STRING_TYPE)) for video_file in 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 not is_device and any([not os.path.exists(video_file) for video_file in video_files]): - raise IOError("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(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: - release_captures(cap_list) - raise - - return (cap_list, cap_framerate, cap_frame_size) - - -def release_captures(cap_list): - # type: (Iterable[VideoCapture]) -> None - """ Close Captures: Calls the release() method on every capture in cap_list. """ - for cap in cap_list: - cap.release() - - -def close_captures(cap_list): - # type: (Iterable[VideoCapture]) -> None - """ Close Captures: Calls the close() method on every capture in cap_list. """ - for cap in cap_list: - cap.close() - - -def validate_capture_framerate(video_names, cap_framerates, framerate=None): - # type: (List[Tuple[str, str]], List[float], Optional[float]) -> Tuple[float, bool] - """ Validate Capture Framerate: Ensures that 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. - VideoFramerateUnavailable: 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 < scenedetect.frame_timecode.MINIMUM_FRAMES_PER_SECOND_FLOAT: - 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 < - scenedetect.frame_timecode.MINIMUM_FRAMES_PER_SECOND_FLOAT] - if unavailable_framerates: - raise VideoFramerateUnavailable(unavailable_framerates) - return (cap_framerate, check_framerate) - - -def validate_capture_parameters(video_names, cap_frame_sizes, check_framerate=False, - cap_framerates=None): - # type: (List[Tuple[str, str]], List[Tuple[int, int]], Optional[bool], - # Optional[List[float]]) -> 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 = scenedetect.frame_timecode.MINIMUM_FRAMES_PER_SECOND_FLOAT - # 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(object): - """ 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. """ - - def __init__(self, video_files, framerate=None, logger=None): - # type: (List[str], Optional[float]) - """ VideoManager Constructor Method (__init__) - - 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 VideoFramerateUnavailable 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. - VideoFramerateUnavailable: 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. - """ - if not video_files: - raise ValueError("At least one string/integer must be passed in the video_files list.") - # These VideoCaptures are only open in this process. - self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=video_files, framerate=framerate) - 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: %.2f 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._downscale_factor = 1 - self._frame_length = get_num_frames(self._cap_list) - - - def set_downscale_factor(self, downscale_factor=None): - # type: (Optional[int]) -> None - """ Set Downscale Factor - sets the downscale/subsample factor of returned frames. - - If N is the downscale_factor, the size of the frames returned becomes - frame_width/N x frame_height/N via subsampling. - - If downscale_factor is None, the downscale factor is computed automatically - based on the current video's resolution. A downscale_factor of 1 indicates - no downscaling. - """ - if downscale_factor is None: - self._downscale_factor = compute_downscale_factor(self.get_framesize()[0]) - else: - if not downscale_factor > 0: - raise InvalidDownscaleFactor() - self._downscale_factor = downscale_factor - if self._logger is not None: - effective_framesize = self.get_framesize_effective() - self._logger.info( - 'Downscale factor set to %d, effective resolution: %d x %d', - self._downscale_factor, effective_framesize[0], effective_framesize[1]) - - - def get_num_videos(self): - # type: () -> int - """ Get Number of Videos - returns the length of the capture list (self._cap_list), - representing the number of videos the VideoManager has opened. - - Returns: - int: Number of videos, equal to length of capture list. - """ - return len(self._cap_list) - - - def get_video_paths(self): - # type: () -> List[str] - """ Get Video Paths - returns 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_framerate(self): - # type: () -> float - """ Get Framerate - returns 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: - float: Framerate, in frames/sec. - """ - return self._cap_framerate - - - def get_base_timecode(self): - # type: () -> FrameTimecode - """ Get Base Timecode - returns 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 - 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 object set to frame 0/time 00:00:00 with the video(s) framerate. - """ - return FrameTimecode(timecode=0, fps=self._cap_framerate) - - - def get_current_timecode(self): - # type: () -> FrameTimecode - """ Get Current Timecode - returns a FrameTimecode object at current VideoManager position. - - Returns: - FrameTimecode: Timecode at the current VideoManager position. - """ - return self._curr_time - - - def get_framesize(self): - # type: () -> Tuple[int, int] - """ Get Frame Size - returns the frame size of the video(s) open in the - VideoManager's capture objects. - - Returns: - Tuple[int, int]: Video frame size in the form (width, height) where width - and height represent the size of the video frame in pixels. - """ - return self._cap_framesize - - - def get_framesize_effective(self): - # type: () -> Tuple[int, int] - """ Get Frame Size - returns the frame size of the video(s) open in the - VideoManager's capture objects, divided by the current downscale factor. - - Returns: - Tuple[int, int]: Video frame size in the form (width, height) where width - and height represent the size of the video frame in pixels. - """ - return [num_pixels / self._downscale_factor for num_pixels in self._cap_framesize] - - - def set_duration(self, duration=None, start_time=None, end_time=None): - # type: (Optional[FrameTimecode], Optional[FrameTimecode], Optional[FrameTimecode]) -> None - """ Set Duration - sets the duration/length of the video(s) to decode, as well as - the start/end times. Must be called before start() is called, otherwise a - VideoDecodingInProgress exception will be thrown. May be called after 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 < 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.get_frames() + 1) - self._frame_length -= self._start_time.get_frames() - - 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 start(self): - # type: () -> 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 stop() before this method if - start() has already been called after initial construction. - """ - if self._started: - raise VideoDecodingInProgress() - - self._started = True - self._get_next_cap() - self.seek(self._start_time) - - - def seek(self, timecode): - # type: (FrameTimecode) -> bool - """ Seek - seeks forwards to the passed timecode. - - Only supports seeking forwards (i.e. timecode must be greater than the - current VideoManager position). Can only be used after the start() - method has been called. - - Arguments: - timecode (FrameTimecode): Time in video to seek forwards to. - - Returns: - bool: True if seeking succeeded, False if no more frames / end of video. - - Raises: - VideoDecoderNotStarted: Must call start() before this method. - """ - if not self._started: - raise VideoDecoderNotStarted() - - while self._curr_time < timecode: - if self._curr_cap is None and not self._get_next_cap(): - return False - if self._curr_cap.grab(): - self._curr_time += 1 - else: - if not self._get_next_cap(): - return False - return True - - - def release(self): - # type: () -> None - """ Release (cv2.VideoCapture method), releases all open capture(s). """ - release_captures(self._cap_list) - self._cap_list = [] - self._started = False - - - def reset(self): - # type: () -> None - """ Reset - Reopens captures passed to the constructor of the VideoManager. - - Can only be called after the release() method has been called. - - Raises: - VideoDecodingInProgress: Must call release() before this method. - """ - if self._started: - raise VideoDecodingInProgress() - - 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, index=None): - # type: (int, Optional[int]) -> 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 - elif capture_prop == cv2.CAP_PROP_POS_FRAMES: - return self._curr_time - elif index is None: - index = 0 - return self._cap_list[index].get(capture_prop) - - - def grab(self): - # type: () -> bool - """ Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. - - Returns: - bool: True if a frame was grabbed, False otherwise. - - Raises: - VideoDecoderNotStarted: Must call start() before this method. - """ - if not self._started: - raise VideoDecoderNotStarted() - - grabbed = False - if self._curr_cap is not None and self._end_of_video != True: - while not grabbed: - grabbed = self._curr_cap.grab() - if not grabbed and not self._get_next_cap(): - break - else: - self._curr_time += 1 - if self._end_time is not None and self._curr_time > self._end_time: - grabbed = False - self._last_frame = None - return grabbed - - - def retrieve(self): - # type: () -> Tuple[bool, Union[None, numpy.ndarray]] - """ Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. - - Frame returned corresponds to last call to get(). - - Returns: - Tuple[bool, Union[None, numpy.ndarray]]: Returns tuple of - (True, frame_image) if a frame was grabbed during the last call - to grab(), and where frame_image is a numpy ndarray of the - decoded frame, otherwise returns (False, None). - - Raises: - VideoDecoderNotStarted: Must call start() before this method. - """ - if not self._started: - raise VideoDecoderNotStarted() - - retrieved = False - if self._curr_cap is not None and self._end_of_video != True: - while not retrieved: - retrieved, self._last_frame = self._curr_cap.retrieve() - if not retrieved and not self._get_next_cap(): - break - if self._downscale_factor > 1: - self._last_frame = self._last_frame[ - ::self._downscale_factor, ::self._downscale_factor, :] - 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): - # type: () -> Tuple[bool, Union[None, numpy.ndarray]] - """ Read (cv2.VideoCapture method) - retrieves and returns a frame. - - Returns: - Tuple[bool, Union[None, numpy.ndarray]]: Returns tuple of - (True, frame_image) if a frame was grabbed, where frame_image - is a numpy ndarray of the decoded frame, otherwise (False, None). - - Raises: - VideoDecoderNotStarted: Must call start() before this method. - """ - if not self._started: - raise VideoDecoderNotStarted() - - read_frame = False - if self._curr_cap is not None and self._end_of_video != True: - while not read_frame: - read_frame, self._last_frame = self._curr_cap.read() - if not read_frame and not self._get_next_cap(): - break - if self._downscale_factor > 1: - self._last_frame = self._last_frame[ - ::self._downscale_factor, ::self._downscale_factor, :] - if self._end_time is not None and self._curr_time > self._end_time: - read_frame = False - self._last_frame = None - if read_frame: - self._curr_time += 1 - return (read_frame, self._last_frame) - - - def _get_next_cap(self): - # type: () -> 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 - diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 0e67e565..563ea269 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -1,263 +1,22 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# See the included LICENSE files or one of the above URLs for more information. -# -# 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. If using a -# source distribution, these programs can be obtained from following URLs -# (note that mkvmerge is a part of the MKVToolNix package): -# -# FFmpeg: [ https://ffmpeg.org/download.html ] -# mkvmerge: [ https://mkvtoolnix.download/downloads.html ] -# -# Also note that Linux users can likely obtain them from their package -# manager (e.g. `sudo apt-get install ffmpeg`). -# -# 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. -# -# 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. -# - -""" PySceneDetect `scenedetect.video_splitter` Module - -The `scenedetect.video_splitter` module contains functions to split videos -with a scene list using external tools (e.g. `mkvmerge`, `ffmpeg`), as well -as functions to check if the tools are available. - -These functions are mainly intended for use by the PySceneDetect command -line interface (the `scenedetect` command). - -Certain distributions of PySceneDetect may include the above software. If -using a source distribution, these programs can be obtained from following -URLs (note that mkvmerge is a part of the MKVToolNix package): - - * 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 (e.g. `sudo apt-get install ffmpeg`). - -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. -""" - -# Standard Library Imports -import logging -import subprocess -import math -import time -from string import Template - -# Third-Party Library Imports -from scenedetect.platform import tqdm - - -## -## Command Availability Checking Functions -## - -def is_mkvmerge_available(): - # type: () -> bool - """ Is mkvmerge Available: Gracefully checks if mkvmerge command is available. - - Returns: - (bool) True if the mkvmerge command is available, 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(): - # type: () -> bool - """ Is ffmpeg Available: Gracefully checks if ffmpeg command is available. - - Returns: - (bool) True if the ffmpeg command is available, False otherwise. - """ - ret_val = None - try: - ret_val = subprocess.call(['ffmpeg', '-v', 'quiet']) - except OSError: - return False - if ret_val is not None and ret_val != 1: - return False - return True - - -## -## Split Video Functions -## - -def split_video_mkvmerge(input_video_paths, scene_list, output_file_prefix, - video_name, suppress_output=False): - # type: (List[str], List[FrameTimecode, FrameTimecode], Optional[str], - # Optional[bool]) -> None - """ Calls the mkvmerge command on the input video(s), splitting it at the - passed timecodes, where each scene is written in sequence from 001. """ - - if not input_video_paths or not scene_list: - return - - logging.info('Splitting input video%s using mkvmerge, output path template:\n %s', - 's' if len(input_video_paths) > 1 else '', output_file_prefix) - - ret_val = None - # mkvmerge automatically appends '-$SCENE_NUMBER'. - output_file_name = output_file_prefix.replace('-${SCENE_NUMBER}', '') - output_file_name = output_file_prefix.replace('-$SCENE_NUMBER', '') - output_file_template = Template(output_file_name) - output_file_name = output_file_template.safe_substitute( - VIDEO_NAME=video_name, - SCENE_NUMBER='') - - try: - call_list = ['mkvmerge'] - if suppress_output: - call_list.append('--quiet') - call_list += [ - '-o', output_file_name, - '--split', - #'timecodes:%s' % ','.join( - # [start_time.get_timecode() for start_time, _ in scene_list[1:]]), - 'parts:%s' % ','.join( - ['%s-%s' % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list]), - ' +'.join(input_video_paths)] - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() - processing_start_time = time.time() - ret_val = subprocess.call(call_list) - if not suppress_output: - print('') - logging.info('Average processing speed %.2f frames/sec.', - float(total_frames) / (time.time() - processing_start_time)) - except OSError: - logging.error('mkvmerge could not be found on the system.' - ' Please install mkvmerge to enable video output support.') - raise - if ret_val is not None and ret_val != 0: - logging.error('Error splitting video (mkvmerge returned %d).', ret_val) - - -def split_video_ffmpeg(input_video_paths, scene_list, output_file_template, video_name, - arg_override='-c:v libx264 -preset fast -crf 21 -c:a copy', - hide_progress=False, suppress_output=False): - # type: (List[str], List[Tuple[FrameTimecode, FrameTimecode]], Optional[str], - # Optional[str], Optional[bool]) -> None - """ Calls the ffmpeg command on the input video(s), generating a new video for - each scene based on the start/end timecodes. """ - - if not input_video_paths or not scene_list: - return - - logging.info( - 'Splitting input video%s using ffmpeg, output path template:\n %s', - 's' if len(input_video_paths) > 1 else '', output_file_template) - - if len(input_video_paths) > 1: - # TODO: Add support for splitting multiple/appended input videos. - # https://trac.ffmpeg.org/wiki/Concatenate#samecodec - # Requires generating a temporary file list for ffmpeg. - logging.error( - 'Sorry, splitting multiple appended/concatenated input videos with' - ' ffmpeg is not supported yet. This feature will be added to a future' - ' version of PySceneDetect. In the meantime, you can try using the' - ' -c / --copy option with the split-video to use mkvmerge, which' - ' generates less accurate output, but supports multiple input videos.') - raise NotImplementedError() - - arg_override = arg_override.replace('\\"', '"') +"""DEPRECATED""" - ret_val = None - arg_override = arg_override.split(' ') - filename_template = Template(output_file_template) - scene_num_format = '%0' - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' +import warnings - try: - progress_bar = None - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() - if tqdm and not hide_progress: - progress_bar = tqdm(total=total_frames, unit='frame', miniters=1) - processing_start_time = time.time() - for i, (start_time, end_time) in enumerate(scene_list): - duration = (end_time - start_time) - # Fix FFmpeg start timecode frame shift. - start_time -= 1 - call_list = ['ffmpeg'] - if suppress_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 += [ - '-y', - '-ss', - start_time.get_timecode(), - '-i', - input_video_paths[0]] - call_list += arg_override - call_list += [ - '-strict', - '-2', - '-t', - duration.get_timecode(), - '-sn', - filename_template.safe_substitute( - VIDEO_NAME=video_name, - SCENE_NUMBER=scene_num_format % (i + 1)) - ] - ret_val = subprocess.call(call_list) - if not suppress_output and i == 0 and len(scene_list) > 1: - logging.info( - 'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...') - if ret_val != 0: - break - if progress_bar: - progress_bar.update(duration.get_frames()) - if progress_bar: - print('') - logging.info('Average processing speed %.2f frames/sec.', - float(total_frames) / (time.time() - processing_start_time)) - except OSError: - logging.error('ffmpeg could not be found on the system.' - ' Please install ffmpeg to enable video output support.') - if ret_val is not None and ret_val != 0: - logging.error('Error splitting video (ffmpeg returned %d).', ret_val) +warnings.warn( + "The `video_splitter` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) +from scenedetect.output.video import * # noqa: E402, F403 diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py new file mode 100644 index 00000000..48f25087 --- /dev/null +++ b/scenedetect/video_stream.py @@ -0,0 +1,222 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""``scenedetect.video_stream`` Module + +This module contains the :class:`VideoStream` class, which provides a library agnostic +interface for video input. To open a video by path, use :func:`scenedetect.open_video`: + +.. code:: python + + from scenedetect import open_video + video = open_video('video.mp4') + while True: + frame = video.read() + if frame is False: + break + print("Read %d frames" % video.frame_number) + +You can also optionally specify a framerate and a specific backend library to use. Unless specified, +OpenCV will be used as the video backend. See :mod:`scenedetect.backends` for a detailed example. + +New :class:`VideoStream ` implementations can be +tested by adding it to the test suite in `tests/test_video_stream.py`. +""" + +import typing as ty +from abc import ABC, abstractmethod +from fractions import Fraction + +import numpy as np + +from scenedetect.common import FrameTimecode, TimecodeLike + + +class SeekError(Exception): + """Either an unrecoverable error happened while attempting to seek, or the underlying + stream is not seekable (additional information will be provided when possible). + + 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.""" + + def __init__(self, message: str = "Unknown backend error."): + """ + Arguments: + message: Additional context the backend can provide for the open failure. + """ + super().__init__(message) + + +class FrameRateUnavailable(VideoOpenFailure): + """Exception instance to provide consistent error messaging across backends when the video frame + rate is unavailable or cannot be calculated. Subclass of VideoOpenFailure.""" + + def __init__(self): + super().__init__( + "Unable to obtain video framerate! Specify `framerate` manually, or" + " re-encode/re-mux the video and try again." + ) + + +## +## VideoStream Interface (Base Class) +## + + +class VideoStream(ABC): + """Interface which all video backends must implement.""" + + # + # 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 + + # + # Backend Identification + # + + BACKEND_NAME: ty.ClassVar[str] + """Unique name used to identify this backend. Each subclass must set this to a unique str.""" + + # + # Abstract Properties + # + + @property + @abstractmethod + def path(self) -> str: + """Video or device path.""" + ... + + @property + @abstractmethod + def name(self) -> str: + """Name of the video, without extension, or device.""" + ... + + @property + @abstractmethod + def is_seekable(self) -> bool: + """True if seek() is allowed, False otherwise.""" + ... + + @property + @abstractmethod + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction (e.g. Fraction(24000, 1001)).""" + ... + + @property + @abstractmethod + def duration(self) -> FrameTimecode | None: + """Duration of the stream as a FrameTimecode, or None if non terminating.""" + ... + + @property + @abstractmethod + def frame_size(self) -> tuple[int, int]: + """Size of each video frame in pixels as a tuple of (width, height).""" + ... + + @property + @abstractmethod + def aspect_ratio(self) -> float: + """Pixel aspect ratio as a float (1.0 represents square pixels).""" + ... + + @property + @abstractmethod + def position(self) -> FrameTimecode: + """Current position within stream as 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.""" + ... + + @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.""" + ... + + @property + @abstractmethod + def frame_number(self) -> int: + """Current position within stream as the frame number. + + Will return 0 until the first frame is `read`.""" + ... + + # + # Abstract Methods + # + + @abstractmethod + 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: 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. + """ + ... + + @abstractmethod + def reset(self) -> None: + """Close and re-open the VideoStream (equivalent to seeking back to beginning).""" + ... + + @abstractmethod + 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). + + 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. + + May not be supported on all backend types or inputs (e.g. cameras). + + 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). + """ + ... 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 c1df7bf1..00000000 --- a/setup.cfg +++ /dev/null @@ -1,13 +0,0 @@ - -[metadata] -description-file = package-info.rst - -[bdist_wheel] -universal=0 - -[aliases] -test=pytest - -[tool:pytest] -addopts = --verbose -python_files = tests/*.py diff --git a/setup.py b/setup.py deleted file mode 100644 index c04ba9e4..00000000 --- a/setup.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# - -""" PySceneDetect setup.py - -To install PySceneDetect: - - python setup.py install - -To run the PySceneDetect unit tests (requires testvideo.mp4, link below): - - python setup.py test - -You can obtain the required testvideo.mp4 from the PySceneDetect [resources -branch](https://github.com/Breakthrough/PySceneDetect/tree/resources) on Github, -or the following URL: - - https://raw.githubusercontent.com/Breakthrough/PySceneDetect/resources/tests/testvideo.mp4 - -""" - -# Standard Library Imports -import sys - -from setuptools import setup - - -if sys.version_info < (2, 7) or (sys.version_info >= (3, 0) and sys.version_info < (3, 3)): - print('PySceneDetect requires at least Python 2.7 or 3.3 to run.') - sys.exit(1) - - -def get_requires(include_opencv=False): - # type: (bool) -> List[str] - """ Get Requires: Returns a list of required packages PySceneDetect depends on. - - Arguments: - include_opencv (bool): Whether to include the cv2 module in the returned module - list or not (default is False). Package may not be able to be installed via - pip, thus the default behaviour is to have users install it separately for now. - """ - requires = ['numpy', 'Click'] - if include_opencv: - requires += ['opencv-python'] - return requires - - -setup( - name='scenedetect', - version='0.5', - description="A cross-platform, OpenCV-based video scene detection program and Python library. ", - long_description=open('package-info.rst').read(), - author='Brandon Castellano', - author_email='brandon248@gmail.com', - url='https://github.com/Breakthrough/PySceneDetect', - license="BSD 3-Clause", - keywords="video computer-vision analysis", - install_requires=get_requires(), # OpenCV must be installed separately so it is excluded. - extras_require={'progress_bar': ['tqdm']}, - setup_requires=['pytest-runner'], - tests_require=['pytest'], - packages=['scenedetect', - 'scenedetect.detectors', - 'scenedetect.cli'], - package_data={'': ['../LICENSE*', '../USAGE.md', '../package-info.rst']}, - #include_package_data = True, # Must leave this to the default. - #test_suite="unitest.py", # Auto-detects tests from setup.cfg - entry_points={"console_scripts": ["scenedetect=scenedetect:main"]}, - 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 :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Topic :: Multimedia :: Video', - 'Topic :: Multimedia :: Video :: Conversion', - 'Topic :: Multimedia :: Video :: Non-Linear Editor', - 'Topic :: Utilities' - ] -) - diff --git a/tests/__init__.py b/tests/__init__.py index db9c2e0a..c616990f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,34 +1,18 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. +# 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. # - -""" PySceneDetect Unit Test Suite +"""PySceneDetect Unit Test Suite To run all available tests run `pytest -v` from the parent directory (i.e. the root project folder of PySceneDetect containing the scenedetect/ and tests/ folders). This will automatically find and run all of the test cases in the tests/ folder and display the results. """ - diff --git a/tests/api_test.py b/tests/api_test.py deleted file mode 100644 index 91b63f83..00000000 --- a/tests/api_test.py +++ /dev/null @@ -1,83 +0,0 @@ - -# -# PySceneDetect v0.5 API Test Script -# -# NOTE: This file can only be used with development versions of PySceneDetect, -# and gives a high-level overview of how the new API will look and work. -# This file is for development and testing purposes mostly, although it -# also serves as a base for further example and test programs. -# - -from __future__ import print_function -import os - -import scenedetect -from scenedetect.video_manager import VideoManager -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.stats_manager import StatsManager -from scenedetect.detectors import ContentDetector - -STATS_FILE_PATH = 'api_test_statsfile.csv' - -def test_api(): - - print("Running PySceneDetect API test...") - - print("PySceneDetect version being used: %s" % str(scenedetect.__version__)) - - # Create a video_manager point to video file testvideo.mp4. Note that multiple - # videos can be appended by simply specifying more file paths in the list - # passed to the VideoManager constructor. Note that appending multiple videos - # requires that they all have the same frame size, and optionally, framerate. - video_manager = VideoManager(['testvideo.mp4']) - stats_manager = StatsManager() - scene_manager = SceneManager(stats_manager) - # Add ContentDetector algorithm (constructor takes detector options like threshold). - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - - try: - # If stats file exists, load it. - if os.path.exists(STATS_FILE_PATH): - # Read stats from CSV file opened in read mode: - with open(STATS_FILE_PATH, 'r') as stats_file: - stats_manager.load_from_csv(stats_file, base_timecode) - - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 20.0 # 00:00:20.000 - # Set video_manager duration to read frames from 00:00:00 to 00:00:20. - video_manager.set_duration(start_time=start_time, end_time=end_time) - - # Set downscale factor to improve processing speed. - video_manager.set_downscale_factor() - - # Start video_manager. - video_manager.start() - - # Perform scene detection on video_manager. - scene_manager.detect_scenes(frame_source=video_manager) - - # Obtain list of detected scenes. - scene_list = scene_manager.get_scene_list(base_timecode) - # Like FrameTimecodes, each scene in the scene_list can be sorted if the - # list of scenes becomes unsorted. - - print('List of scenes obtained:') - 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(),)) - - # We only write to the stats file if a save is required: - if stats_manager.is_save_required(): - with open(STATS_FILE_PATH, 'w') as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode) - - finally: - video_manager.release() - -if __name__ == "__main__": - test_api() - diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..dee805a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,211 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""PySceneDetect Test Configuration + +This file includes all pytest configuration for running PySceneDetect's tests. + +These tests rely on the files in the tests/resources/ folder in the "resources" branch of +the PySceneDetect git repository. These files can be checked out via git by running the +following from the root of the repo: + + 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 + +Note that currently these tests create some temporary files which are not yet cleaned up. +""" + +# TODO: Properly cleanup temporary files. + +import logging +import os +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: ty.AnyStr) -> ty.AnyStr: + """Returns the absolute path to a (relative) path of a file that + should exist within the tests/ directory. + + Throws FileNotFoundError if the file could not be found. + """ + if not os.path.exists(path): + raise FileNotFoundError( + 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 +""" + ) + return path + + +# +# Pytest Hooks +# + + +def pytest_assertrepr_compare(op, left, right): + if isinstance(left, str) and isinstance(right, str) and op == "in": + return [ + "Did not find expected output in test.", + "", + "Expected to find:", + "", + *left.splitlines(), + "", + "Actual output:", + "", + *right.splitlines(), + ] + return [] + + +# +# Test Case Fixtures +# + + +@pytest.fixture(autouse=True) +def no_logs_gte_error(caplog): + """Ensure no log messages with error severity or higher were reported during test execution.""" + EXCLUDED_MODULES = set() + yield + errors = [ + record + for record in caplog.get_records("call") + if record.levelno >= logging.ERROR and record.module not in EXCLUDED_MODULES + ] + assert not errors, "Test failed due to presence of one or more logs with ERROR severity." + + +@pytest.fixture +def test_video_file() -> str: + """Simple test video containing both fast cuts and fades/dissolves.""" + return check_exists("tests/resources/testvideo.mp4") + + +@pytest.fixture +def test_movie_clip() -> str: + """Movie clip containing fast cuts.""" + 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.""" + return check_exists("tests/resources/corrupt_frame.mp4") + + +@pytest.fixture +def rotated_video_file() -> str: + """Video containing a corrupted frame causing a decode failure.""" + return check_exists("tests/resources/issue-134-rotate.mp4") + + +@pytest.fixture +def test_image_sequence() -> str: + """Path to a short image sequence (from counter.mp4).""" + return "tests/resources/counter/frame%03d.png" + + +@pytest.fixture +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 new file mode 100644 index 00000000..1ec880ec --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,183 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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 API Tests + +These tests demonstrate common workflow patterns used when integrating the PySceneDetect API.""" + + +def test_api_detect(test_video_file: str): + """Demonstrate usage of the `detect()` function to process a complete video.""" + from scenedetect import ContentDetector, detect + + scene_list = detect(test_video_file, ContentDetector()) + for i, scene in enumerate(scene_list): + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") + + +def test_api_detect_start_end_time(test_video_file: str): + """Demonstrate usage of the `detect()` function to process a subset of a video.""" + from scenedetect import ContentDetector, detect + + # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (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(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") + + +def test_api_detect_stats(test_video_file: str): + """Demonstrate usage of the `detect()` function to generate a statsfile.""" + from scenedetect import ContentDetector, detect + + detect(test_video_file, ContentDetector(), stats_file_path="frame_metrics.csv") + + +def test_api_scene_manager(test_video_file: str): + """Demonstrate how to use a SceneManager to implement a function similar to `detect()`.""" + from scenedetect import ContentDetector, SceneManager, open_video + + video = open_video(test_video_file) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video=video) + scene_list = scene_manager.get_scene_list() + for i, scene in enumerate(scene_list): + 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): + """Demonstrate how to use a SceneManager to process a subset of the input video.""" + from scenedetect import ContentDetector, SceneManager, open_video + + video = open_video(test_video_file) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). + # See test_api_timecode_types() for examples of each format. + start_time = 200 # Start at frame (int) 200 + end_time = 15.0 # End at 15 seconds (float) + video.seek(start_time) + 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(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(): + """Demonstrate all different types of timecodes that can be used.""" + from scenedetect import FrameTimecode + + base_timecode = FrameTimecode(timecode=0, fps=10.0) + # Frames (int) + timecode = base_timecode + 1 + assert timecode.frame_num == 1 + # Seconds (float) + timecode = base_timecode + 1.0 + assert timecode.frame_num == 10 + # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') + timecode = base_timecode + "00:00:01.500" + assert timecode.frame_num == 15 + # Seconds (str, 'SSSs' or 'SSSS.SSSs') + timecode = base_timecode + "1.5s" + assert timecode.frame_num == 15 + + +def test_api_stats_manager(test_video_file: str): + """Demonstrate using a StatsManager to save per-frame statistics to disk.""" + from scenedetect import ContentDetector, SceneManager, StatsManager, open_video + + video = open_video(test_video_file) + scene_manager = SceneManager(stats_manager=StatsManager()) + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video=video) + # Save per-frame statistics to disk. + filename = f"{test_video_file}.stats.csv" + assert scene_manager.stats_manager is not None + scene_manager.stats_manager.save_to_csv(csv_file=filename) + + +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, FrameTimecode, SceneManager, open_video + + # Callback to invoke on the first frame of every new scene detection. + 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() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video=video, callback=on_new_scene) + + +def test_api_device_callback(test_video_file: str): + """Demonstrate how to use a webcam/device/pipe and a callback function. + Instead of calling `open_video()`, an existing `cv2.VideoCapture` can be used by + wrapping it with a `VideoCaptureAdapter.`""" + import cv2 + import numpy + + 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, 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) + video = VideoCaptureAdapter(cap) + # Now `video` can be used as normal with a `SceneManager`. If the input is non-terminating, + # either set `end_time/duration` when calling `detect_scenes`, or call `scene_manager.stop()`. + total_frames = 1000 + 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 new file mode 100644 index 00000000..1f8c03be --- /dev/null +++ b/tests/test_backend_opencv.py @@ -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) 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 scenedetect.backend.opencv Tests + +This file includes unit tests for the scenedetect.backend.opencv module that implements the +VideoStreamCv2 ('opencv') backend. These tests validate behaviour specific to this backend. + +For VideoStream tests that validate conformance, see test_video_stream.py. +""" + +import cv2 + +from scenedetect import ContentDetector, SceneManager +from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 + +GROUND_TRUTH_CAPTURE_ADAPTER_TEST = [1, 90, 210] +GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [180, 394] + + +def test_open_image_sequence(test_image_sequence: str): + """Test opening an image sequence. Currently, only VideoStreamCv2 supports this.""" + sequence = VideoStreamCv2(test_image_sequence, framerate=25.0) + 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) + assert sequence.position == 29 + + +def test_capture_adapter(test_movie_clip: str): + """Test that the VideoCaptureAdapter works with SceneManager.""" + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + adapter = VideoCaptureAdapter(cap) + assert adapter.read() is not False + + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + 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.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 new file mode 100644 index 00000000..17bf0170 --- /dev/null +++ b/tests/test_backend_pyav.py @@ -0,0 +1,93 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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 scenedetect.backend.pyav Tests + +This file includes unit tests for the scenedetect.backend.pyav module that implements the +VideoStreamAv ('pyav') backend. These tests validate behaviour specific to this backend. + +For VideoStream tests that validate conformance, see test_video_stream.py. +""" + +import av + +from scenedetect.backends.pyav import MAX_CONSECUTIVE_DECODE_FAILURES, VideoStreamAv + + +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 = auto_close(VideoStreamAv(path_or_io=video_file, threading_mode=None)) + assert stream.is_seekable + stream.seek(50) + for _ in range(10): + assert stream.read() is not False + + +def _make_invalid_data_error() -> Exception: + # AVERROR_INVALIDDATA ("Invalid data found when processing input"). + return av.error.InvalidDataError( # type: ignore[attr-defined] + 1094995529, "Invalid data found when processing input" + ) + + +class _FaultInjectingContainer: + """Wraps an `av.InputContainer`, replacing `decode` to inject decode errors. + `InputContainer.decode` itself is a read-only Cython attribute, so we swap the whole + container for this proxy instead.""" + + def __init__(self, container, decode): + self._container = container + self._decode = decode + + def decode(self, *args, **kwargs): + return self._decode(self._container, *args, **kwargs) + + def __getattr__(self, name): + return getattr(self._container, name) + + +def test_read_tolerates_corrupt_frame(test_video_file: str, auto_close): + """A decode error partway through the stream must be skipped, not stop decoding.""" + stream = auto_close(VideoStreamAv(test_video_file)) + injected = False + + def fault_injecting_decode(container, *args, **kwargs): + nonlocal injected + for frame_index, frame in enumerate(container.decode(*args, **kwargs)): + if not injected and frame_index == 5: + injected = True + raise _make_invalid_data_error() + yield frame + + stream._container = _FaultInjectingContainer(stream._container, fault_injecting_decode) + for frame in range(20): + assert stream.read(decode=False) is not False, f"Failed on frame {frame}!" + assert injected + assert stream.decode_failures == 1 + + +def test_read_gives_up_after_consecutive_failures(test_video_file: str, caplog, auto_close): + """After too many consecutive decode failures, read() must return False, not hang.""" + stream = auto_close(VideoStreamAv(test_video_file)) + + def always_failing_decode(container, *args, **kwargs): + raise _make_invalid_data_error() + yield # pragma: no cover - makes this a generator function. + + stream._container = _FaultInjectingContainer(stream._container, always_failing_decode) + assert stream.read(decode=False) is False + assert stream.decode_failures == MAX_CONSECUTIVE_DECODE_FAILURES + # Giving up emits an ERROR log by design; verify it then clear it so the autouse + # `no_logs_gte_error` fixture doesn't fail the test. + assert any("consecutive" in record.message for record in caplog.records) + caplog.clear() diff --git a/tests/test_benchmark_evaluator.py b/tests/test_benchmark_evaluator.py new file mode 100644 index 00000000..6f6a1784 --- /dev/null +++ b/tests/test_benchmark_evaluator.py @@ -0,0 +1,322 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Unit tests for the benchmark evaluator. Exercises matching, tolerance, and aggregation logic with +synthetic predictions versus ground-truth lists. Covers TRECVID-SBD style scoring as documented in +``benchmark/README.md``. +""" + +from __future__ import annotations + +import math +from pathlib import Path + +from benchmark.evaluator import ( + EventInterval, + EventMetrics, + GroundTruth, + Prediction, + _score_fade_transitions, + _score_hard_cuts, + evaluate, + score_video, +) + +# --------------------------------------------------------------------- # +# Hard-cut matching (the core of every detector's score) +# --------------------------------------------------------------------- # + + +def test_hard_exact_match_tolerance_zero(): + m, offsets = _score_hard_cuts( + predicted_cuts=[10, 20, 30], ground_truth_cuts=[10, 20, 30], tolerance=0 + ) + assert (m.matched, m.false_positives, m.missed) == (3, 0, 0) + assert offsets == [0, 0, 0] + assert m.precision == 1.0 + assert m.recall == 1.0 + assert m.f1 == 1.0 + + +def test_hard_tolerance_one_admits_one_frame_offset(): + m, offsets = _score_hard_cuts(predicted_cuts=[11, 19], ground_truth_cuts=[10, 20], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (2, 0, 0) + assert sorted(offsets) == [1, 1] + + +def test_hard_tolerance_one_rejects_two_frame_offset(): + m, _ = _score_hard_cuts(predicted_cuts=[12], ground_truth_cuts=[10], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (0, 1, 1) + + +def test_hard_greedy_picks_closer_match(): + # Two ground-truth cuts at 10 and 14. Single prediction at 13 is closer + # to 14. The greedy matcher must claim 14 first; 10 then becomes a miss. + m, offsets = _score_hard_cuts(predicted_cuts=[13], ground_truth_cuts=[10, 14], tolerance=5) + assert (m.matched, m.false_positives, m.missed) == (1, 0, 1) + assert offsets == [1] + + +def test_hard_equidistant_tie_resolves_deterministically(): + # Prediction at 12 is exactly 2 frames from both ground-truth cuts at 10 + # and 14. Tie-break is by stable sort order (i, j) which prefers the + # lower ground-truth index. + m, offsets = _score_hard_cuts(predicted_cuts=[12], ground_truth_cuts=[10, 14], tolerance=5) + assert (m.matched, m.false_positives, m.missed) == (1, 0, 1) + assert offsets == [2] + + +def test_hard_one_to_one_no_double_assignment(): + # Two predictions both within tolerance of a single ground-truth cut. + # Only one can match; the other is a false positive. + m, _ = _score_hard_cuts(predicted_cuts=[10, 11], ground_truth_cuts=[10], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (1, 1, 0) + + +def test_hard_empty_inputs(): + m, _ = _score_hard_cuts(predicted_cuts=[], ground_truth_cuts=[], tolerance=0) + assert (m.matched, m.false_positives, m.missed) == (0, 0, 0) + # Division-by-zero defenses. + assert m.precision == 0.0 + assert m.recall == 0.0 + assert m.f1 == 0.0 + + +def test_hard_empty_preds_with_nonempty_gt(): + # No predictions: every ground-truth cut is a miss. + m, offsets = _score_hard_cuts(predicted_cuts=[], ground_truth_cuts=[10, 20], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (0, 0, 2) + assert offsets == [] + assert m.recall == 0.0 + + +def test_hard_empty_gt_with_nonempty_preds(): + # No ground truth: every prediction is a false positive. + m, offsets = _score_hard_cuts(predicted_cuts=[10], ground_truth_cuts=[], tolerance=1) + assert (m.matched, m.false_positives, m.missed) == (0, 1, 0) + assert offsets == [] + assert m.precision == 0.0 + + +# --------------------------------------------------------------------- # +# Fade transition matching (ClipShots-style typed ground truth) +# --------------------------------------------------------------------- # + + +def test_fade_pred_inside_interval_is_match(): + m, consumed = _score_fade_transitions(predicted_cuts=[15], intervals=[EventInterval(10, 20)]) + assert (m.matched, m.false_positives, m.missed) == (1, 0, 0) + assert consumed == {0} + + +def test_fade_pred_outside_interval_not_consumed(): + m, consumed = _score_fade_transitions(predicted_cuts=[25], intervals=[EventInterval(10, 20)]) + assert (m.matched, m.false_positives, m.missed) == (0, 0, 1) + assert consumed == set() # passed through to hard scorer + + +def test_fade_multiple_preds_in_same_interval(): + # First prediction inside the interval is the match; the second is a + # false positive. Both are consumed (do not leak to hard matching). + m, consumed = _score_fade_transitions( + predicted_cuts=[12, 18], intervals=[EventInterval(10, 20)] + ) + assert (m.matched, m.false_positives, m.missed) == (1, 1, 0) + assert consumed == {0, 1} + + +def test_fade_interval_endpoints_inclusive(): + m, _ = _score_fade_transitions(predicted_cuts=[10, 20], intervals=[EventInterval(10, 20)]) + # Both endpoints hit the same interval, so 1 match + 1 false positive. + assert (m.matched, m.false_positives, m.missed) == (1, 1, 0) + + +# --------------------------------------------------------------------- # +# score_video: fade transitions take priority over hard cuts +# --------------------------------------------------------------------- # + + +def test_score_video_fade_consumes_pred_before_hard(): + # Prediction at 15 falls inside the fade interval [10, 20]. Even + # though the hard ground-truth cut at 16 is within tolerance, the + # fade scorer claims the prediction first and the hard scorer + # never sees it. + ground_truth = GroundTruth(hard_cuts=[16], fades=[EventInterval(10, 20)]) + v = score_video([15], ground_truth, tolerance=1, elapsed=0.0) + assert v.fades.matched == 1 + assert v.hard_cuts.matched == 0 # hard match was preempted by the fade + assert v.hard_cuts.missed == 1 # hard ground-truth cut at 16 is now a miss + + +def test_score_video_pred_outside_fade_falls_to_hard(): + ground_truth = GroundTruth(hard_cuts=[30], fades=[EventInterval(10, 20)]) + v = score_video([30], ground_truth, tolerance=0, elapsed=0.0) + assert v.fades.matched == 0 + assert v.fades.missed == 1 # fade still missed + assert v.hard_cuts.matched == 1 + + +# --------------------------------------------------------------------- # +# Mean absolute offset (localization error on hard-cut matches only) +# --------------------------------------------------------------------- # + + +def test_mean_abs_offset_only_hard_matches_tolerance_zero(): + ground_truth = GroundTruth( + hard_cuts=[100, 200, 300], + fades=[EventInterval(50, 60)], + ) + # Predictions: fade hit at 55 (excluded from offset), hard match at 100 + # (offset 0). 201 and 302 are outside tolerance 0. + v = score_video([55, 100, 201, 302], ground_truth, 0, 0.0) + assert v.hard_cuts.matched == 1 + assert v.mean_abs_offset == 0.0 + + +def test_mean_abs_offset_only_hard_matches_tolerance_one(): + ground_truth = GroundTruth( + hard_cuts=[100, 200, 300], + fades=[EventInterval(50, 60)], + ) + # Same setup at tolerance 1: 100 (offset 0) and 201 (offset 1) match; + # 302 is out of tolerance. Mean offset is (0 + 1) / 2 = 0.5. + v = score_video([55, 100, 201, 302], ground_truth, 1, 0.0) + assert v.hard_cuts.matched == 2 + assert v.mean_abs_offset == 0.5 + + +def test_mean_abs_offset_nan_when_no_matches(): + ground_truth = GroundTruth(hard_cuts=[1000]) + v = score_video([5], ground_truth, 0, 0.0) + assert math.isnan(v.mean_abs_offset) + + +def test_benchmark_result_mean_abs_offset_nan_when_no_matches_across_videos(): + # Two videos, both producing zero hard-cut matches. The aggregate offset + # has zero sum and zero count, so nan must propagate at the + # BenchmarkResult level, not just per-video. + predictions = { + Path("a.mp4"): Prediction( + predicted_cuts=[5], + ground_truth=GroundTruth(hard_cuts=[1000]), + elapsed=1.0, + ), + Path("b.mp4"): Prediction( + predicted_cuts=[7], + ground_truth=GroundTruth(hard_cuts=[2000]), + elapsed=1.0, + ), + } + result = evaluate(predictions, tolerance=0) + assert math.isnan(result.mean_abs_offset_hard_cuts) + + +# --------------------------------------------------------------------- # +# Aggregate result: sum-of-counts across videos +# --------------------------------------------------------------------- # + + +def test_benchmark_result_aggregate_matches_sum_of_counts(): + predictions = { + Path("vid_a.mp4"): Prediction( + predicted_cuts=[10, 20], + ground_truth=GroundTruth(hard_cuts=[10, 21]), + elapsed=1.0, + ), + Path("vid_b.mp4"): Prediction( + predicted_cuts=[50, 99], + ground_truth=GroundTruth(hard_cuts=[50, 100]), + elapsed=3.0, + ), + } + # Tolerance 0: only 10 (vid_a) and 50 (vid_b) match exactly. + # Aggregate: 2 matched, 2 false positives, 2 missed. + result_t0 = evaluate(predictions, tolerance=0) + assert result_t0.hard_cuts.matched == 2 + assert result_t0.hard_cuts.false_positives == 2 + assert result_t0.hard_cuts.missed == 2 + # Tolerance 1: both predictions in each video match -> 4 matched, 0 fp, 0 missed. + result_t1 = evaluate(predictions, tolerance=1) + assert result_t1.hard_cuts.matched == 4 + assert result_t1.hard_cuts.false_positives == 0 + assert result_t1.hard_cuts.missed == 0 + # Elapsed: total and mean (independent of tolerance). + assert result_t0.elapsed_total == 4.0 + assert result_t0.elapsed_mean == 2.0 + + +def test_benchmark_result_by_category_buckets_videos(): + predictions = { + Path("a.mp4"): Prediction( + predicted_cuts=[10], + ground_truth=GroundTruth(hard_cuts=[10], category="news"), + elapsed=1.0, + ), + Path("b.mp4"): Prediction( + predicted_cuts=[20], + ground_truth=GroundTruth(hard_cuts=[20], category="sports"), + elapsed=1.0, + ), + Path("c.mp4"): Prediction( + predicted_cuts=[30], + ground_truth=GroundTruth(hard_cuts=[30], category="news"), + elapsed=1.0, + ), + } + result = evaluate(predictions, tolerance=0) + by_category = result.by_category() + assert set(by_category) == {"news", "sports"} + assert len(by_category["news"].per_video) == 2 + assert len(by_category["sports"].per_video) == 1 + + +def test_benchmark_result_by_category_buckets_untagged_videos_as_unknown(): + # Datasets without category tags (BBC, AutoShot) leave category=None on every + # video. by_category must bucket those under the literal key "unknown". + predictions = { + Path("a.mp4"): Prediction( + predicted_cuts=[10], + ground_truth=GroundTruth(hard_cuts=[10]), # category defaults to None + elapsed=1.0, + ), + Path("b.mp4"): Prediction( + predicted_cuts=[20], + ground_truth=GroundTruth(hard_cuts=[20]), + elapsed=1.0, + ), + } + result = evaluate(predictions, tolerance=0) + by_category = result.by_category() + assert set(by_category) == {"unknown"} + assert len(by_category["unknown"].per_video) == 2 + + +# --------------------------------------------------------------------- # +# EventMetrics arithmetic +# --------------------------------------------------------------------- # + + +def test_event_metrics_addition(): + a = EventMetrics(matched=3, false_positives=1, missed=2) + b = EventMetrics(matched=5, false_positives=2, missed=1) + c = a + b + assert (c.matched, c.false_positives, c.missed) == (8, 3, 3) + + +def test_event_metrics_to_dict_round_trip(): + m = EventMetrics(matched=3, false_positives=1, missed=1) + d = m.to_dict() + assert d["matched"] == 3 + assert d["false_positives"] == 1 + assert d["missed"] == 1 + assert d["precision"] == 75.0 # 3 / 4 + assert d["recall"] == 75.0 # 3 / 4 + assert d["f1"] == 75.0 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..a807973e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,1435 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2022 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# + +import os +import subprocess + +# 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" + +ALL_DETECTORS = [ + "detect-content", + "detect-threshold", + "detect-adaptive", + "detect-hist", + "detect-hash", +] +ALL_BACKENDS = ["opencv", "pyav"] + +DEFAULT_VIDEO_PATH = "tests/resources/goldeneye.mp4" +DEFAULT_VIDEO_NAME = Path(DEFAULT_VIDEO_PATH).stem +DEFAULT_BACKEND = "opencv" +DEFAULT_STATSFILE = "statsfile.csv" +DEFAULT_TIME = "-s 2s -d 4s" # Seek forward a bit but limit the amount we process. +DEFAULT_DETECTOR = "detect-content" +DEFAULT_CONFIG_FILE = "scenedetect.cfg" # Ensure we default to a "blank" config file. +DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. +DEFAULT_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.""" + + +def invoke_scenedetect( + args: str = "", + 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. + The kwargs are passed to the args format method, for example: + + invoke_scenedetect('-i {VIDEO} {DETECTOR}', VIDEO='file.mp4', DETECTOR='detect-content') + + Providing `output_dir` and `config_file` set -o/--output and -c/--config, respectively. + + Default values are set for any arguments found in the command: + VIDEO -> VIDEO_PATH + VIDEO_NAME -> VIDEO_NAME + DETECTOR -> DEFAULT_DETECTOR + TIME -> DEFAULT_TIME + STATS -> DEFAULT_STATSFILE + BACKEND -> DEFAULT_BACKEND + CONFIG_FILE -> DEFAULT_CONFIG_FILE + """ + value_dict = dict( + VIDEO=DEFAULT_VIDEO_PATH, + VIDEO_NAME=DEFAULT_VIDEO_NAME, + TIME=DEFAULT_TIME, + DETECTOR=DEFAULT_DETECTOR, + STATS=DEFAULT_STATSFILE, + BACKEND=DEFAULT_BACKEND, + ) + value_dict.update(**kwargs) + command = SCENEDETECT_CMD + if output_dir: + command += f" -o {output_dir}" + if config_file: + command += f" -c {config_file}" + command += " " + args.format(**value_dict) + return subprocess.call(command.strip().split(" ")) + + +def test_cli_no_args(): + """Test `scenedetect` command invoked without any arguments.""" + assert invoke_scenedetect(config_file=None) == 0 + + +def test_cli_default_detector(): + """Test `scenedetect` command invoked without a 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).""" + assert invoke_scenedetect(info_command) == 0 + + +def test_cli_time_validate_options(): + """Validate behavior of setting parameters via the `time` command.""" + base_command = "-i {VIDEO} time {TIME} {DETECTOR}" + # Ensure cannot set end and duration together. + assert invoke_scenedetect(base_command, TIME="-s 2.0 -d 6.0 -e 8.0") != 0 + assert invoke_scenedetect(base_command, TIME="-s 2.0 -e 8.0 -d 6.0 ") != 0 + + +def test_cli_time_end(): + """Validate processed frames without start time being set. End time is the end frame to stop at, + but with duration, we stop at start + duration - 1.""" + EXPECTED = """[PySceneDetect] Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 1 | 00:00:00.000 | 10 | 00:00:00.417 | +----------------------------------------------------------------------- +""" + TEST_CASES = [ + "time --end 10", + "time --end 00:00:00.417", + "time --end 0.417", + "time --duration 10", + "time --duration 00:00:00.417", + "time --duration 0.417", + ] + 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(), + ], + text=True, + ) + assert EXPECTED in output, test_case + + +def test_cli_time_start(): + """Validate processed frames with both start and end/duration set. End time is the end frame to + stop at, but with duration, we stop at start + duration - 1.""" + EXPECTED = """[PySceneDetect] Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 4 | 00:00:00.125 | 10 | 00:00:00.417 | +----------------------------------------------------------------------- +""" + TEST_CASES = [ + "time --start 4 --end 10", + "time --start 4 --end 00:00:00.417", + "time --start 4 --end 0.417", + "time --start 4 --duration 7", + "time --start 4 --duration 0.292", + "time --start 4 --duration 00:00:00.292", + ] + 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(), + ], + text=True, + ) + assert EXPECTED in output, test_case + + +def test_cli_time_scene_boundary(): + """Validate frames that are processed when crossing a scene boundary. End time is the end frame + to stop at, but with duration, we stop at start + duration - 1.""" + # ------------------------------------------------------------------------------------- + # | Scene | Frame | PTS | PTS + Duration | Annotation | + # ------------------------------------------------------------------------------------- + # | 1 | 86 | 00:00:03.545 | 00:00:03.587 | Start Frame | + # | 1 | 87 | 00:00:03.587 | 00:00:03.629 | | + # | 1 | 88 | 00:00:03.629 | 00:00:03.670 | | + # | 1 | 89 | 00:00:03.670 | 00:00:03.712 | | + # | 1 | 90 | 00:00:03.712 | 00:00:03.754 | Scene 1 End | + # | 2 | 91 | 00:00:03.754 | 00:00:03.795 | Scene 2 Start | + # | 2 | 92 | 00:00:03.795 | 00:00:03.837 | | + # | 2 | 93 | 00:00:03.837 | 00:00:03.879 | | + # | 2 | 94 | 00:00:03.879 | 00:00:03.921 | | + # | 2 | 95 | 00:00:03.921 | 00:00:03.962 | | + # | 2 | 96 | 00:00:03.962 | 00:00:04.004 | End Frame | + # ------------------------------------------------------------------------------------- + EXPECTED = """ +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 86 | 00:00:03.545 | 90 | 00:00:03.754 | + | 2 | 91 | 00:00:03.754 | 96 | 00:00:04.004 | +----------------------------------------------------------------------- +""" + # End time is the end frame to stop at, but with duration, we stop at start + duration - 1. + TEST_CASES = [ + "time --start 86 --end 96", + "time --start 00:00:03.545 --end 00:00:04.004", + "time --start 3.545 --end 4.004", + "time --start 86 --duration 11", + "time --start 00:00:03.545 --duration 00:00:00.459", + "time --start 3.545 --duration 0.459", + ] + 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(), + ], + text=True, + ) + assert EXPECTED in output, test_case + + +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", + ], + text=True, + ) + assert ( + """ +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 1872 | 00:01:18.036 | 1916 | 00:01:19.913 | + | 2 | 1917 | 00:01:19.913 | 1966 | 00:01:21.999 | + | 3 | 1967 | 00:01:21.999 | 1980 | 00:01:22.582 | +----------------------------------------------------------------------- +""" + in output + ) + assert "00:01:19.913,00:01:21.999" in output + + +@pytest.mark.parametrize("detector_command", ALL_DETECTORS) +def test_cli_detector(detector_command: str): + """Test each detection algorithm.""" + # Ensure all detectors work without a statsfile. + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR}", DETECTOR=detector_command) == 0 + + +@pytest.mark.parametrize("detector_command", ALL_DETECTORS) +def test_cli_detector_with_stats(tmp_path, detector_command: str): + """Test each detection algorithm with a statsfile.""" + # Run with a statsfile twice to ensure the file is populated with those metrics and reloaded. + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", + output_dir=tmp_path, + DETECTOR=detector_command, + ) + == 0 + ) + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", + output_dir=tmp_path, + DETECTOR=detector_command, + ) + == 0 + ) + # TODO: Check for existence of statsfile by trying to load it with the library, + # 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 -s", + output_dir=tmp_path, + ) + == 0 + ) + 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} time {TIME} {DETECTOR} list-scenes -n", + output_dir=tmp_path, + ) + == 0 + ) + 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( + 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 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") +def test_cli_split_video_ffmpeg(tmp_path: Path): + """Test `split-video` command using ffmpeg.""" + + # Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER. + command = f"{SCENEDETECT_CMD} -i {DEFAULT_VIDEO_PATH} -o {tmp_path} time {DEFAULT_TIME} {DEFAULT_DETECTOR} split-video -a".split( + " " + ) + command.append(DEFAULT_FFMPEG_ARGS) + assert subprocess.call(command) == 0 + entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) + assert len(entries) == DEFAULT_NUM_SCENES, entries + [entry.unlink() for entry in entries] + + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c", output_dir=tmp_path + ) + == 0 + ) + entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) + assert len(entries) == DEFAULT_NUM_SCENES + [entry.unlink() for entry in entries] + + command += ["-f", "abc$VIDEO_NAME-123$SCENE_NUMBER"] + assert subprocess.call(command) == 0 + entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*")) + assert len(entries) == DEFAULT_NUM_SCENES, entries + [entry.unlink() for entry in entries] + + # -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero) + assert invoke_scenedetect( + '-i {VIDEO} {DETECTOR} split-video -c -a "-c:v libx264"', + output_dir=tmp_path, + ) + + +@pytest.mark.skipif(condition=not is_mkvmerge_available(), reason="mkvmerge is not available") +def test_cli_split_video_mkvmerge(tmp_path: Path): + """Test `split-video` command using mkvmerge.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m", output_dir=tmp_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", + 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, + ) + + +def test_cli_save_images(tmp_path: Path): + """Test `save-images` command.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images", 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 + # Open one of the created images and make sure it has the correct resolution. + 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(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( + "-i {VIDEO} {DETECTOR} time {TIME} save-images", + VIDEO=rotated_video_file, + 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 + 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_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( + "-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( + "-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp --disable-shift", + 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:] + + +@pytest.mark.parametrize("backend_type", ALL_BACKENDS) +def test_cli_backend(backend_type: str): + """Test setting the `-b`/`--backend` argument.""" + assert ( + invoke_scenedetect("-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}", BACKEND=backend_type) + == 0 + ) + + +def test_cli_backend_unsupported(): + """Ensure setting an invalid backend returns an error.""" + assert ( + invoke_scenedetect("-i {VIDEO} -b {BACKEND} {DETECTOR}", BACKEND="unknown_backend_type") + != 0 + ) + + +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 + # Specifying a detector with load-scenes should be disallowed. + assert invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv" + ) + # Specifying load-scenes several times should be disallowed. + assert invoke_scenedetect( + "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv" + ) + # If `-s`/`--skip-cuts` is specified, the resulting scene list should still be compatible with + # the `load-scenes` command. + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s") == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 + + +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 +1,49 +2,91 +3,211 +""" + with open("test_scene_list.csv", "w") as f: + f.write(scenes_csv) + output = subprocess.check_output( + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "load-scenes", + "-i", + "test_scene_list.csv", + "time", + "-s", + "2s", + "-e", + "10s", + "list-scenes", + ], + text=True, + ) + assert ( + """ +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 49 | 00:00:02.002 | 90 | 00:00:03.754 | + | 2 | 91 | 00:00:03.754 | 210 | 00:00:08.759 | + | 3 | 211 | 00:00:08.759 | 240 | 00:00:10.010 | +----------------------------------------------------------------------- +""" + in output + ) + assert "00:00:03.754,00:00:08.759" in output + + +def test_cli_load_scenes_round_trip(): + """Verify we can use `load-scenes` and get the same scenes as output with `list-scenes`.""" + scenes_csv = """ +Scene Number,Start Frame +1,49 +2,91 +3,211 +""" + with open("test_scene_list.csv", "w") as f: + f.write(scenes_csv) + ground_truth = subprocess.check_output( + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "detect-content", + "list-scenes", + "-f", + "testout.csv", + "time", + "-s", + "200", + "-e", + "400", + ], + text=True, + ) + loaded_first_pass = subprocess.check_output( + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "load-scenes", + "-i", + "testout.csv", + "time", + "-s", + "200", + "-e", + "400", + "list-scenes", + "-f", + "testout2.csv", + ], + text=True, + ) + SPLIT_POINT = " | Scene # | Start Frame | Start Time | End Frame | End Time |" + 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 new file mode 100644 index 00000000..a6a9f283 --- /dev/null +++ b/tests/test_detectors.py @@ -0,0 +1,263 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""PySceneDetect Scene Detection Tests + +These tests ensure that the detection algorithms deliver consistent +results by using known ground truths of scene cut locations in the +test case material. +""" + +import os +from dataclasses import dataclass + +import pytest + +from scenedetect import FrameTimecode, SceneDetector, SceneManager, StatsManager, detect +from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) + +# 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 = (*FAST_CUT_DETECTORS, ThresholdDetector) + +# TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively +# consistent frame scores regardless of resolution. This will ensure that threshold values will hold +# 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` +def get_absolute_path(relative_path: str) -> str: + """Returns the absolute path to a (relative) path of a file that + should exist within the tests/ directory. + + Throws FileNotFoundError if the file could not be found. + """ + abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError( + 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 +""" + ) + return abs_path + + +@dataclass +class TestCase: + __test__ = False + """Properties for detector test cases.""" + path: str + """Path to video for test case.""" + detector: SceneDetector + """Detector instance to use.""" + start_time: int + """Start time as frames.""" + end_time: int + """End time as frames.""" + scene_boundaries: list[int] + """Scene boundaries.""" + + def detect(self): + """Run scene detection for test case. Should only be called once.""" + return detect( + video_path=self.path, + detector=self.detector, + start_time=self.start_time, + end_time=self.end_time, + ) + + +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). 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( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=detector_type(min_scene_len=15), + start_time=1199, + end_time=1450, + scene_boundaries=( + [1199, 1226, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [1199, 1226, 1260, 1281, 1334, 1365] + ), + ), + id=f"{detector_type.__name__}/default", + ) + for detector_type in FAST_CUT_DETECTORS + ] + # goldeneye.mp4 with min_scene_len = 30 + test_cases += [ + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=detector_type(min_scene_len=30), + start_time=1199, + end_time=1450, + scene_boundaries=( + [1199, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [1199, 1260, 1334, 1365] + ), + ), + id=f"{detector_type.__name__}/m=30", + ) + for detector_type in FAST_CUT_DETECTORS + ] + return test_cases + + +def get_fade_in_out_test_cases(): + """Fixture for parameterized test cases that detect fades.""" + # TODO: min_scene_len doesn't seem to be working as intended for ThresholdDetector. + # Possibly related to #278: https://github.com/Breakthrough/PySceneDetect/issues/278 + return [ + pytest.param( + TestCase( + path=get_absolute_path("resources/testvideo.mp4"), + detector=ThresholdDetector(), + start_time=0, + end_time=500, + scene_boundaries=[0, 15, 198, 377], + ), + id="threshold_testvideo_default", + ), + pytest.param( + TestCase( + path=get_absolute_path("resources/fades.mp4"), + detector=ThresholdDetector(), + start_time=0, + end_time=250, + scene_boundaries=[0, 84, 167], + ), + id="threshold_fades_default", + ), + pytest.param( + TestCase( + path=get_absolute_path("resources/fades.mp4"), + detector=ThresholdDetector( + threshold=11.0, + method=ThresholdDetector.Method.FLOOR, + add_final_scene=True, + ), + start_time=0, + end_time=250, + scene_boundaries=[0, 84, 167, 245], + ), + id="threshold_fades_floor", + ), + pytest.param( + TestCase( + path=get_absolute_path("resources/fades.mp4"), + detector=ThresholdDetector( + threshold=243.0, + method=ThresholdDetector.Method.CEILING, + add_final_scene=True, + ), + start_time=0, + end_time=250, + scene_boundaries=[0, 42, 126, 209], + ), + id="threshold_fades_ceil", + ), + ] + + +@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.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 + + +@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.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 + + +def test_detectors_with_stats(test_video_file): + """Test all detectors functionality with a StatsManager.""" + # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). + for detector in ALL_DETECTORS: + video = VideoStreamCv2(test_video_file) + stats = StatsManager() + scene_manager = SceneManager(stats_manager=stats) + scene_manager.add_detector(detector()) + scene_manager.auto_downscale = True + end_time = FrameTimecode("00:00:05", video.frame_rate) + scene_manager.detect_scenes(video=video, end_time=end_time) + initial_scene_len = len(scene_manager.get_scene_list()) + assert initial_scene_len > 0, "Test case must have at least one scene." + # Re-analyze using existing stats manager. + scene_manager = SceneManager(stats_manager=stats) + scene_manager.add_detector(detector()) + video.reset() + scene_manager.auto_downscale = True + 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 6f42f838..00000000 --- a/tests/test_frame_timecode.py +++ /dev/null @@ -1,241 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" 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. -""" - -# Standard project pylint disables for unit tests using pytest. -# pylint: disable=no-self-use, protected-access, multiple-statements, invalid-name -# pylint: disable=redefined-outer-name, pointless-statement, expression-not-assigned -# pylint: disable=unneeded-not - - -# Third-Party Library Imports -import pytest - -# Standard Library Imports -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.frame_timecode import MINIMUM_FRAMES_PER_SECOND_FLOAT - - -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=MINIMUM_FRAMES_PER_SECOND_FLOAT / 2) - # Test positive framerates. - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=0, fps=MINIMUM_FRAMES_PER_SECOND_FLOAT).frame_num == 0 - assert FrameTimecode(timecode=0, fps=10).frame_num == 0 - assert FrameTimecode(timecode=0, fps=MINIMUM_FRAMES_PER_SECOND_FLOAT * 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.0', 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] ('%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 == 1 - 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 == 59 - 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 == 3599 - 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 not x != FrameTimecode(timecode=1.0, fps=10.0) - assert x != FrameTimecode(timecode=10.0, fps=10.0) - assert not x == FrameTimecode(timecode=10.0, fps=10.0) - # Comparing FrameTimecodes with different framerates raises a TypeError. - with pytest.raises(TypeError): x == FrameTimecode(timecode=1.0, fps=100.0) - with pytest.raises(TypeError): 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): x == '0x' - with pytest.raises(ValueError): x == 'x00:00:00.000' - with pytest.raises(TypeError): x == [0] - with pytest.raises(TypeError): x == (0,) - with pytest.raises(TypeError): x == [0, 1, 2, 3] - with pytest.raises(TypeError): 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): 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) - 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): FrameTimecode('00:00:02.000', fps=20.0) == x - 10 - 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 new file mode 100644 index 00000000..e4b3fe18 --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,40 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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. +# +"""PySceneDetect scenedetect.platform Tests + +This file includes unit tests for the scenedetect.platform module, containing +all platform/library/OS-specific compatibility fixes. +""" + +import platform + +import pytest + +from scenedetect.platform import CommandTooLong, invoke_command + + +def test_invoke_command(): + """Ensures the function exists and is callable without throwing + an exception.""" + if platform.system() == "Windows": + invoke_command(["cmd"]) + else: + invoke_command(["echo"]) + + +def test_long_command(): + """[Windows Only] Ensures that a command string too large to be handled + is translated to the correct exception for error handling. + """ + if platform.system() == "Windows": + with pytest.raises(CommandTooLong): + invoke_command(["x" * 2**15]) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 003286c7..b5388e7a 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -1,153 +1,263 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect scenedetect.scene_manager Tests +"""PySceneDetect scenedetect.scene_manager Tests -This file includes unit tests for the scenedetect.scene_manager module (specifically, -the SceneManager object, used to coordinate using SceneDetector objects on video -capture/frame sources like the scenedetect.video_decoder.VideoManager object, or -a cv2.VideoCapture object). +This file includes unit tests for the scenedetect.scene_manager.SceneManager class, +which applies SceneDetector algorithms on VideoStream backends. +""" -In addition to the SceneManager class, these tests also require the PySceneDetect -FrameTimecode, VideoManager, and VideoManagerAsync objects, and the OpenCV -VideoCapture object. +import pytest -These unit tests test the VideoManager object with respect to object construction, -testing argument format/limits, opening videos and grabbing frames, and appending -multiple videos together. +from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.common import FrameTimecode +from scenedetect.detectors import AdaptiveDetector, ContentDetector +from scenedetect.scene_manager import SceneManager, expand_scenes_to_bounds -These tests rely on the testvideo.mp4 test video file, available by checking out the -PySceneDetect git repository "resources" branch, or the following URL to download it -directly: https://github.com/Breakthrough/PySceneDetect/tree/resources/tests -Alternatively, the TEST_VIDEO_FILE constant can be replaced with any valid video file. -""" +TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] -# Standard project pylint disables for unit tests using pytest. -# pylint: disable=no-self-use, protected-access, multiple-statements, invalid-name -# pylint: disable=redefined-outer-name +def test_scene_list(test_video_file): + """Test SceneManager get_scene_list method with VideoStreamCv2/ContentDetector.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) -# Standard Library Imports -import os + video_fps = video.frame_rate + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:10", video_fps) -# Third-Party Library Imports -import pytest -import cv2 + assert end_time.frame_num > start_time.frame_num -# PySceneDetect Library Imports -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.video_manager import VideoManager -from scenedetect.detectors import ContentDetector + video.seek(start_time) + sm.auto_downscale = True + num_frames = sm.detect_scenes(video=video, end_time=end_time) -TEST_VIDEO_FILE = 'testvideo.mp4' + assert num_frames == (end_time.frame_num - start_time.frame_num) + scene_list = sm.get_scene_list() + assert scene_list + # Each scene is in the format (Start Timecode, End Timecode) + assert len(scene_list[0]) == 2 -@pytest.fixture -def test_video_file(): - # type: () -> str - """ Fixture for test video file path (ensures file exists). + # First scene should start at start_time and last scene should end at end_time. + assert scene_list[0][0] == start_time + assert scene_list[-1][1] == end_time - Access in test case by adding a test_video_file argument to obtain the path. - """ - if not os.path.exists(TEST_VIDEO_FILE): - raise FileNotFoundError( - 'Test video file (%s) must be present to run test cases' % TEST_VIDEO_FILE) - return TEST_VIDEO_FILE + for i, _ in enumerate(scene_list): + 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). + assert scene_list[i - 1][1] == scene_list[i][0] -def test_content_detect(test_video_file): - """ Test SceneManager with VideoManager and ContentDetector. """ - vm = VideoManager([test_video_file]) +def test_get_scene_list_start_in_scene(test_video_file): + """Test SceneManager `get_scene_list()` method with the `start_in_scene` flag.""" + video = VideoStreamCv2(test_video_file) sm = SceneManager() sm.add_detector(ContentDetector()) - try: - video_fps = vm.get_framerate() - start_time = FrameTimecode('00:00:00', video_fps) - end_time = FrameTimecode('00:00:05', video_fps) + video_fps = video.frame_rate + # End time must be short enough that we won't detect any scenes. + end_time = FrameTimecode(25, video_fps) + sm.auto_downscale = True + sm.detect_scenes(video=video, end_time=end_time) + # Should be an empty list. + assert len(sm.get_scene_list()) == 0 + # Should be a list with a single element spanning the video duration. + scene_list = sm.get_scene_list(start_in_scene=True) + assert len(scene_list) == 1 + assert scene_list[0][0] == 0 + assert scene_list[0][1] == end_time + + +# 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] = [] + + def get_callback_lambda(self): + """For testing using a lambda..""" + return lambda image, frame_num: self._callback(image, frame_num) - vm.set_duration(start_time=start_time, end_time=end_time) - vm.set_downscale_factor() + def get_callback_func(self): + """For testing using a callback function.""" - vm.start() - num_frames = sm.detect_scenes(frame_source=vm) - assert num_frames == end_time.get_frames() + 1 + def callback(image, frame_num): + nonlocal self + self._callback(image, frame_num) - finally: - vm.release() + return callback + def _callback(self, image, frame_num): + self.scene_list.append(frame_num) -def test_content_detect_opencv_videocap(test_video_file): - """ Test SceneManager with cv2.VideoCapture and ContentDetector. """ - cap = cv2.VideoCapture(test_video_file) + +def test_detect_scenes_callback(test_video_file): + """Test SceneManager detect_scenes method with a callback function. + + Note that the API signature of the callback will undergo breaking changes in v1.0. + """ + video = VideoStreamCv2(test_video_file) sm = SceneManager() sm.add_detector(ContentDetector()) - try: - video_fps = cap.get(cv2.CAP_PROP_FPS) - duration = FrameTimecode('00:00:05', video_fps) + fake_callback = FakeCallback() + + 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 - num_frames = sm.detect_scenes(frame_source=cap, end_time=duration) + _ = sm.detect_scenes( + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() + ) + 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:] - assert num_frames == duration.get_frames() + # Perform same test using callback function instead of lambda. + sm.clear() + sm.add_detector(ContentDetector()) + fake_callback = FakeCallback() + video.seek(start_time) - finally: - cap.release() + _ = sm.detect_scenes(video=video, end_time=end_time, callback=fake_callback.get_callback_func()) + 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_scene_list(test_video_file): - """ Test SceneManager get_scene_list method with VideoManager/ContentDetector. """ - vm = VideoManager([test_video_file]) +def test_detect_scenes_callback_adaptive(test_video_file): + """Test SceneManager detect_scenes method with a callback function and a detector which + requires frame buffering. + + Note that the API signature of the callback will undergo breaking changes in v1.0. + """ + video = VideoStreamCv2(test_video_file) sm = SceneManager() - sm.add_detector(ContentDetector()) + sm.add_detector(AdaptiveDetector()) + + fake_callback = FakeCallback() - try: - base_timecode = vm.get_base_timecode() - video_fps = vm.get_framerate() - start_time = FrameTimecode('00:00:00', video_fps) - end_time = FrameTimecode('00:00:10', video_fps) + 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 - vm.set_duration(start_time=start_time, end_time=end_time) - vm.set_downscale_factor() + _ = sm.detect_scenes( + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() + ) + 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:] - vm.start() - num_frames = sm.detect_scenes(frame_source=vm) + # Perform same test using callback function instead of lambda. + sm.clear() + sm.add_detector(AdaptiveDetector()) + fake_callback = FakeCallback() + video.seek(start_time) - assert num_frames == end_time.get_frames() + 1 + _ = sm.detect_scenes(video=video, end_time=end_time, callback=fake_callback.get_callback_func()) + 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:] - scene_list = sm.get_scene_list(base_timecode) - for i, _ in enumerate(scene_list): - 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). - assert scene_list[i-1][1] == scene_list[i][0] +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()) - finally: - vm.release() + 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 69128475..5e7360ae 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -1,107 +1,53 @@ -# -*- coding: utf-8 -*- # -# 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) 2012-2018 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. # -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect scenedetect.stats_manager Tests +"""PySceneDetect scenedetect.stats_manager Tests This file includes unit tests for the scenedetect.stats_manager module (specifically, the StatsManager object, used to coordinate caching of frame metrics to/from a CSV file to speed up subsequent calls to detect_scenes on a SceneManager. -These tests rely on the SceneManager, VideoManager, and ContentDetector classes. +These tests rely on the SceneManager, VifdeoStreamCv2, and ContentDetector classes. These tests also require the testvideo.mp4 (see test_scene_manager.py for download -instructions), however any other valid video file can be used as well by setting the -global variable TEST_VIDEO_FILE to the name of the file to use. +instructions), however any other valid video file can be used as well by modifying +the fixture test_video_file in conftest.py. Additionally, these tests will create, write to, and read from files which use names TEST_STATS_FILE_XXXXXXXXXXXX.csv, where the X's will be replaced with random digits. These files will be deleted, if possible, after the tests are completed running. """ -# Standard project pylint disables for unit tests using pytest. -# pylint: disable=no-self-use, protected-access, multiple-statements, invalid-name -# pylint: disable=redefined-outer-name +import csv +from pathlib import Path - -# Standard Library Imports -import os -import random - -# Third-Party Library Imports import pytest -# PySceneDetect Library Imports -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.video_manager import VideoManager +from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.common import FrameTimecode from scenedetect.detectors import ContentDetector - -from scenedetect.platform import get_csv_reader -from scenedetect.platform import get_csv_writer - -from scenedetect.stats_manager import StatsManager -from scenedetect.stats_manager import FrameMetricRegistered -from scenedetect.stats_manager import StatsFileCorrupt -from scenedetect.stats_manager import StatsFileFramerateMismatch -# TODO: The following exceptions still require test cases: -from scenedetect.stats_manager import FrameMetricNotRegistered -from scenedetect.stats_manager import NoMetricsRegistered -from scenedetect.stats_manager import NoMetricsSet - - -TEST_VIDEO_FILE = 'testvideo.mp4' - -# TODO: Replace TEST_STATS_FILES with a @pytest.fixture called generate_stats_file. -# It should generate the path to a random stats file for use in a test case. -TEST_STATS_FILES = ['TEST_STATS_FILE'] * 4 -TEST_STATS_FILES = ['%s_%012d.csv' % (stats_file, random.randint(0, 10**12)) - for stats_file in TEST_STATS_FILES] - - -@pytest.fixture -def test_video_file(): - # type: () -> str - """ Fixture for test video file path (ensures file exists). - - Access in test case by adding a test_video_file argument to obtain the path. - """ - if not os.path.exists(TEST_VIDEO_FILE): - raise FileNotFoundError( - 'Test video file (%s) must be present to run test cases' % TEST_VIDEO_FILE) - for stats_file in TEST_STATS_FILES: - if os.path.exists(stats_file): - raise FileExistsError('Existing file would be overwritten by running test, aborting.') - return TEST_VIDEO_FILE +from scenedetect.scene_manager import SceneManager +from scenedetect.stats_manager import ( + COLUMN_NAME_FRAME_NUMBER, + COLUMN_NAME_TIMECODE, + StatsFileCorrupt, + StatsManager, +) def test_metrics(): - """ Test StatsManager metric registration/setting/getting with a set of pre-defined + """Test StatsManager metric registration/setting/getting with a set of pre-defined key-value pairs (metric_dict). """ - metric_dict = {'some_metric': 1.2345, 'another_metric': 6.7890} + metric_dict = {"some_metric": 1.2345, "another_metric": 6.7890} metric_keys = list(metric_dict.keys()) stats = StatsManager() @@ -111,8 +57,6 @@ def test_metrics(): stats.register_metrics(metric_keys) assert not stats.is_save_required() - with pytest.raises(FrameMetricRegistered): - stats.register_metrics(metric_keys) assert not stats.metrics_exist(frame_key, metric_keys) assert stats.get_metrics(frame_key, metric_keys) == [None] * len(metric_keys) @@ -125,218 +69,130 @@ def test_metrics(): assert stats.metrics_exist(frame_key, metric_keys[1:]) assert stats.get_metrics(frame_key, metric_keys) == [ - metric_dict[metric_key] for metric_key in metric_keys] + metric_dict[metric_key] for metric_key in metric_keys + ] def test_detector_metrics(test_video_file): - """ Test passing StatsManager to a SceneManager and using it for storing the frame metrics + """Test passing StatsManager to a SceneManager and using it for storing the frame metrics from a ContentDetector. """ - video_manager = VideoManager([test_video_file]) + video = VideoStreamCv2(test_video_file) stats_manager = StatsManager() scene_manager = SceneManager(stats_manager) - #base_timecode = video_manager.get_base_timecode() - - assert not stats_manager._registered_metrics scene_manager.add_detector(ContentDetector()) - # add_detector should trigger register_metrics in the StatsManager. - assert stats_manager._registered_metrics - - try: - video_fps = video_manager.get_framerate() - start_time = FrameTimecode('00:00:00', video_fps) - duration = FrameTimecode('00:00:20', video_fps) - - video_manager.set_duration(start_time=start_time, end_time=duration) - video_manager.set_downscale_factor() - video_manager.start() - scene_manager.detect_scenes(frame_source=video_manager) - - # Check that metrics were written to the StatsManager. - assert stats_manager._frame_metrics - frame_key = min(stats_manager._frame_metrics.keys()) - assert stats_manager._frame_metrics[frame_key] - assert stats_manager.metrics_exist(frame_key, list(stats_manager._registered_metrics)) - - # Since we only added 1 detector, the number of metrics from get_metrics - # should equal the number of metric keys in _registered_metrics. - assert len(stats_manager.get_metrics( - frame_key, list(stats_manager._registered_metrics))) == len( - stats_manager._registered_metrics) - - finally: - video_manager.release() - - -def test_load_empty_stats(test_video_file): - """ Test loading an empty stats file, ensuring it results in no errors. """ - try: - stats_file = open(TEST_STATS_FILES[0], 'w') - - stats_file.close() - stats_file = open(TEST_STATS_FILES[0], 'r') - - stats_manager = StatsManager() - - stats_reader = get_csv_reader(stats_file) - stats_manager.load_from_csv(stats_reader) + video_fps = video.frame_rate + duration = FrameTimecode("00:00:05", video_fps) + scene_manager.auto_downscale = True + scene_manager.detect_scenes(video=video, duration=duration) + # Check that metrics were written to the StatsManager. + assert stats_manager.get_metrics(0, ContentDetector.METRIC_KEYS) + + +def test_load_empty_stats(tmp_path: Path): + """Test loading an empty stats file, ensuring it results in no errors.""" + path = tmp_path.joinpath("stats.csv") + with open(path, "w"): + pass + stats_manager = StatsManager() + stats_manager.load_from_csv(path) - finally: - stats_file.close() - os.remove(TEST_STATS_FILES[0]) +def test_save_no_detect_scenes(tmp_path: Path): + """Test saving without calling detect_scenes.""" + path = tmp_path.joinpath("stats.csv") + stats_manager = StatsManager() + stats_manager.save_to_csv(path) -def test_load_hardcoded_file(test_video_file): - """ Test loading a stats file with some hard-coded data generated by this test case. """ - from scenedetect.stats_manager import COLUMN_NAME_FPS - from scenedetect.stats_manager import COLUMN_NAME_FRAME_NUMBER - from scenedetect.stats_manager import COLUMN_NAME_TIMECODE +def test_load_hardcoded_file(tmp_path: Path): + """Test loading a stats file with some hard-coded data generated by this test case.""" + path = tmp_path.joinpath("stats.csv") stats_manager = StatsManager() - stats_file = open(TEST_STATS_FILES[0], 'w') - - try: - stats_writer = get_csv_writer(stats_file) + with open(path, "w") as stats_file: + stats_writer = csv.writer(stats_file, lineterminator="\n") - some_metric_key = 'some_metric' + some_metric_key = "some_metric" some_metric_value = 1.2 some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) some_frame_timecode = base_timecode + some_frame_key # Write out a valid file. - stats_writer.writerow([COLUMN_NAME_FPS, '%.10f' % base_timecode.get_framerate()]) - stats_writer.writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) + stats_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) stats_writer.writerow( - [some_frame_key, some_frame_timecode.get_timecode(), str(some_metric_value)]) - - stats_file.close() - - stats_file = open(TEST_STATS_FILES[0], 'r') - stats_manager.load_from_csv(csv_file=stats_file, base_timecode=base_timecode) + [some_frame_key + 1, some_frame_timecode.get_timecode(), str(some_metric_value)] + ) - # Check that we decoded the correct values. - assert stats_manager.metrics_exist(some_frame_key, [some_metric_key]) - assert stats_manager.get_metrics( - some_frame_key, [some_metric_key])[0] == pytest.approx(some_metric_value) + stats_manager.load_from_csv(path) - finally: - stats_file.close() - os.remove(TEST_STATS_FILES[0]) + # Check that we decoded the correct values. + assert stats_manager.metrics_exist(some_frame_key, [some_metric_key]) + assert stats_manager.get_metrics(some_frame_key, [some_metric_key])[0] == pytest.approx( + some_metric_value + ) -def test_save_load_from_video(test_video_file): - """ Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and +def test_save_load_from_video(test_video_file, tmp_path: Path): + """Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and loading the file back to ensure the loaded frame metrics agree with those that were saved. """ - video_manager = VideoManager([test_video_file]) + video = VideoStreamCv2(test_video_file) stats_manager = StatsManager() scene_manager = SceneManager(stats_manager) - base_timecode = video_manager.get_base_timecode() - scene_manager.add_detector(ContentDetector()) - try: - video_fps = video_manager.get_framerate() - start_time = FrameTimecode('00:00:00', video_fps) - duration = FrameTimecode('00:00:20', video_fps) - - video_manager.set_duration(start_time=start_time, end_time=duration) - video_manager.set_downscale_factor() - video_manager.start() - scene_manager.detect_scenes(frame_source=video_manager) + video_fps = video.frame_rate + duration = FrameTimecode("00:00:05", video_fps) - with open(TEST_STATS_FILES[0], 'w') as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode) + scene_manager.auto_downscale = True + scene_manager.detect_scenes(video, duration=duration) - stats_manager_new = StatsManager() + path = tmp_path.joinpath("stats.csv") + stats_manager.save_to_csv(csv_file=path) - with open(TEST_STATS_FILES[0], 'r') as stats_file: - stats_manager_new.load_from_csv(stats_file, base_timecode) + metrics = stats_manager.metric_keys - # Choose the first available frame key and compare all metrics in both. - frame_key = min(stats_manager._frame_metrics.keys()) - metric_keys = list(stats_manager._registered_metrics) + stats_manager_new = StatsManager() - assert stats_manager.metrics_exist(frame_key, metric_keys) - orig_metrics = stats_manager.get_metrics(frame_key, metric_keys) - new_metrics = stats_manager_new.get_metrics(frame_key, metric_keys) + stats_manager_new.load_from_csv(path) + # Compare the first 5 frames. Frame 0 won't have any metrics for this detector. + for frame in range(1, 5 + 1): + assert stats_manager.metrics_exist(frame, metrics) + orig_metrics = stats_manager.get_metrics(frame, metrics) + new_metrics = stats_manager_new.get_metrics(frame, metrics) for i, metric_val in enumerate(orig_metrics): assert metric_val == pytest.approx(new_metrics[i]) - finally: - os.remove(TEST_STATS_FILES[0]) - video_manager.release() - - -def test_load_corrupt_stats(test_video_file): - """ Test loading a corrupted stats file created by outputting data in the wrong format. """ - from scenedetect.stats_manager import COLUMN_NAME_FPS - from scenedetect.stats_manager import COLUMN_NAME_FRAME_NUMBER - from scenedetect.stats_manager import COLUMN_NAME_TIMECODE +def test_load_corrupt_stats(tmp_path: Path): + """Test loading a corrupted stats file created by outputting data in the wrong format.""" stats_manager = StatsManager() - stats_files = [open(stats_file, 'wt') for stats_file in TEST_STATS_FILES] - try: - - stats_writers = [get_csv_writer(stats_file) for stats_file in stats_files] + path = tmp_path.joinpath("stats.csv") + with open(path, "w") as stats_file: + stats_writer = csv.writer(stats_file, lineterminator="\n") - some_metric_key = 'some_metric' + some_metric_key = "some_metric" some_metric_value = str(1.2) some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) some_frame_timecode = base_timecode + some_frame_key # Write out some invalid files. - # File 0: Blank FPS [StatsFileCorrupt] - stats_writers[0].writerow([COLUMN_NAME_FPS]) - stats_writers[0].writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) - stats_writers[0].writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) - - # File 1: Invalid FPS [StatsFileCorrupt] - stats_writers[1].writerow([COLUMN_NAME_FPS, '%0.10f' % 0.0000001]) - stats_writers[1].writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) - stats_writers[1].writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) - - # File 2: Wrong FPS [StatsFileFramerateMismatch] - stats_writers[2].writerow( - [COLUMN_NAME_FPS, '%.10f' % (base_timecode.get_framerate() / 2.0)]) - stats_writers[2].writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) - stats_writers[2].writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) - - # File 3: Wrong Header Names [StatsFileCorrupt] - stats_writers[3].writerow([COLUMN_NAME_FPS, '%.10f' % base_timecode.get_framerate()]) - stats_writers[3].writerow( - [COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key]) - stats_writers[3].writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) - - for stats_file in stats_files: stats_file.close() - - stats_files = [open(stats_file, 'rt') for stats_file in TEST_STATS_FILES] - with pytest.raises(StatsFileCorrupt): - stats_manager.load_from_csv(stats_files[0], base_timecode) - with pytest.raises(StatsFileCorrupt): - stats_manager.load_from_csv(stats_files[1], base_timecode) - with pytest.raises(StatsFileFramerateMismatch): - stats_manager.load_from_csv(stats_files[2], base_timecode) - with pytest.raises(StatsFileCorrupt): - stats_manager.load_from_csv(stats_files[3], base_timecode) + # File #0: Wrong Header Names [StatsFileCorrupt] + # Swapped timecode & frame number. + stats_writer.writerow([COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key]) + stats_writer.writerow( + [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value] + ) - finally: - for stats_file in stats_files: stats_file.close() - for stats_file in TEST_STATS_FILES: os.remove(stats_file) + stats_file.close() + with pytest.raises(StatsFileCorrupt): + stats_manager.load_from_csv(path) 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_manager.py b/tests/test_video_manager.py deleted file mode 100644 index 6b347d1b..00000000 --- a/tests/test_video_manager.py +++ /dev/null @@ -1,283 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/pyscenedetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] -# -# Copyright (C) 2012-2018 Brandon Castellano . -# -# 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 the Numpy, OpenCV, click, tqdm, and pytest libraries. -# 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. -# - -""" PySceneDetect scenedetect.video_manager Tests - -This file includes unit tests for the scenedetect.video_manager module, acting as -a video container/decoder, allowing seeking and concatenation of multiple sources. - -These unit tests test the VideoManager object with respect to object construction, -testing argument format/limits, opening videos and grabbing frames, and appending -multiple videos together. These tests rely on testvideo.mp4, available in the -PySceneDetect git repository "resources" branch. - -These tests rely on the testvideo.mp4 test video file, available by checking out the -PySceneDetect git repository "resources" branch, or the following URL to download it -directly: https://github.com/Breakthrough/PySceneDetect/tree/resources/tests -""" - -# Standard project pylint disables for unit tests using pytest. -# pylint: disable=no-self-use, protected-access, multiple-statements, invalid-name -# pylint: disable=redefined-outer-name - - -# Standard Library Imports -import os - -# Third-Party Library Imports -import pytest -import cv2 - -# PySceneDetect Library Imports -from scenedetect.video_manager import VideoManager -from scenedetect.video_manager import VideoOpenFailure - -# TODO: The following exceptions still require test cases: -from scenedetect.video_manager import VideoDecodingInProgress -from scenedetect.video_manager import VideoDecoderNotStarted - -# TODO: Need to implement a mock VideoCapture to test the exceptions below. -# TODO: The following exceptions still require test cases: -from scenedetect.video_manager import VideoFramerateUnavailable -from scenedetect.video_manager import VideoParameterMismatch - - -TEST_VIDEO_FILE = 'testvideo.mp4' # Video file used by test_video_file fixture. - - -@pytest.fixture -def test_video_file(): - # type: () -> str - """ Fixture for test video file path (ensures file exists). - - Access in test case by adding a test_video_file argument to obtain the path. - """ - if not os.path.exists(TEST_VIDEO_FILE): - raise FileNotFoundError( - 'Test video file (%s) must be present to run test cases' % TEST_VIDEO_FILE) - return TEST_VIDEO_FILE - - -def test_video_params(test_video_file): - """ Test VideoManager get_framerate/get_framesize methods on test_video_file. """ - try: - cap = cv2.VideoCapture(test_video_file) - video_manager = VideoManager([test_video_file] * 2) - assert cap.isOpened() - assert video_manager.get_framerate() == pytest.approx(cap.get(cv2.CAP_PROP_FPS)) - assert video_manager.get_framesize() == ( - pytest.approx(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - pytest.approx(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) - finally: - cap.release() - video_manager.release() - - -def test_start_release(test_video_file): - """ Test VideoManager start/release methods on 3 appended videos. """ - video_manager = VideoManager([test_video_file] * 2) - # *must* call release() after start() or video manager process will be rogue. - # - # The start method is the only big usage differences between the - # VideoManager and cv2.VideoCapture objects from the point of view - # of a SceneManager (the other VideoManager methods function - # independently of it's job as a frame source). - try: - video_manager.start() - # even if exception thrown here, video manager process will stop. - finally: - video_manager.release() - - -def test_get_property(test_video_file): - """ Test VideoManager get method on test_video_file. """ - video_manager = VideoManager([test_video_file] * 3) - video_framerate = video_manager.get_framerate() - assert video_manager.get(cv2.CAP_PROP_FPS) == pytest.approx(video_framerate) - assert video_manager.get(cv2.CAP_PROP_FPS, 1) == pytest.approx(video_framerate) - assert video_manager.get(cv2.CAP_PROP_FPS, 2) == pytest.approx(video_framerate) - video_manager.release() - - -def test_wrong_video_files_type(): - """ Test VideoManager constructor (__init__ method) with invalid video_files - argument types to trigger a ValueError exception. """ - with pytest.raises(ValueError): VideoManager([0, 1, 2]) - with pytest.raises(ValueError): VideoManager([0, 'somefile']) - with pytest.raises(ValueError): VideoManager(['somefile', 1, 2, 'somefile']) - with pytest.raises(ValueError): VideoManager([-1]) - - -def test_wrong_framerate_type(test_video_file): - """ Test VideoManager constructor (__init__ method) with an invalid framerate - argument types to trigger a TypeError exception. """ - with pytest.raises(TypeError): VideoManager([test_video_file], framerate=int(0)) - with pytest.raises(TypeError): VideoManager([test_video_file], framerate=int(10)) - with pytest.raises(TypeError): VideoManager([test_video_file], framerate='10') - VideoManager([test_video_file], framerate=float(10)).release() - - -def test_video_open_failure(): - """ Test VideoManager constructor (__init__ method) with invalid filename(s) - and device IDs to trigger an IOError/VideoOpenFailure exception. """ - # Attempt to open non-existing video files should raise an IOError. - with pytest.raises(IOError): VideoManager(['fauxfile.mp4']) - with pytest.raises(IOError): VideoManager(['fauxfile.mp4', 'otherfakefile.mp4']) - # Attempt to open 99th video device should raise a VideoOpenFailure since - # the OpenCV VideoCapture open() method will likely fail (unless the test - # case computer has 100 webcams or more...) - with pytest.raises(VideoOpenFailure): VideoManager([99]) - # Test device IDs > 100. - with pytest.raises(VideoOpenFailure): VideoManager([120]) - with pytest.raises(VideoOpenFailure): VideoManager([255]) - - -def test_grab_retrieve(test_video_file): - """ Test VideoManager grab/retrieve methods. """ - video_manager = VideoManager([test_video_file] * 2) - base_timecode = video_manager.get_base_timecode() - try: - video_manager.start() - assert video_manager.get_current_timecode() == base_timecode - for i in range(1, 10): - # VideoManager.grab() -> bool - ret_val = video_manager.grab() - assert ret_val - assert video_manager.get_current_timecode() == base_timecode + i - # VideoManager.retrieve() -> Tuple[bool, numpy.ndarray] - ret_val, frame_image = video_manager.retrieve() - assert ret_val - assert frame_image.shape[0] > 0 - assert video_manager.get_current_timecode() == base_timecode + i - finally: - video_manager.release() - - -def test_read(test_video_file): - """ Test VideoManager read method. """ - video_manager = VideoManager([test_video_file] * 2) - base_timecode = video_manager.get_base_timecode() - try: - video_manager.start() - assert video_manager.get_current_timecode() == base_timecode - for i in range(1, 10): - # VideoManager.read() -> Tuple[bool, numpy.ndarray] - ret_val, frame_image = video_manager.read() - assert ret_val - assert frame_image.shape[0] > 0 - assert video_manager.get_current_timecode() == base_timecode + i - finally: - video_manager.release() - - -def test_seek(test_video_file): - """ Test VideoManager seek method. """ - video_manager = VideoManager([test_video_file] * 2) - base_timecode = video_manager.get_base_timecode() - try: - video_manager.start() - assert video_manager.get_current_timecode() == base_timecode - ret_val, frame_image = video_manager.read() - assert ret_val - assert frame_image.shape[0] > 0 - assert video_manager.get_current_timecode() == base_timecode + 1 - - assert video_manager.seek(base_timecode + 10) - assert video_manager.get_current_timecode() == base_timecode + 10 - ret_val, frame_image = video_manager.read() - assert ret_val - assert frame_image.shape[0] > 0 - assert video_manager.get_current_timecode() == base_timecode + 11 - - finally: - video_manager.release() - - -def test_reset(test_video_file): - """ Test VideoManager reset method. """ - video_manager = VideoManager([test_video_file] * 2) - base_timecode = video_manager.get_base_timecode() - try: - video_manager.start() - assert video_manager.get_current_timecode() == base_timecode - ret_val, frame_image = video_manager.read() - assert ret_val - assert frame_image.shape[0] > 0 - assert video_manager.get_current_timecode() == base_timecode + 1 - - video_manager.release() - video_manager.reset() - - video_manager.start() - assert video_manager.get_current_timecode() == base_timecode - ret_val, frame_image = video_manager.read() - assert ret_val - assert frame_image.shape[0] > 0 - assert video_manager.get_current_timecode() == base_timecode + 1 - - finally: - video_manager.release() - - -def test_multiple_videos(test_video_file): - """ Test VideoManager handling decoding frames across video boundaries. """ - - NUM_FRAMES = 10 - NUM_VIDEOS = 3 - # Open VideoManager and get base timecode. - video_manager = VideoManager([test_video_file] * NUM_VIDEOS) - base_timecode = video_manager.get_base_timecode() - - # List of NUM_VIDEOS VideoManagers pointing to test_video_file. - vm_list = [ - VideoManager([test_video_file]), - VideoManager([test_video_file]), - VideoManager([test_video_file])] - - # Set duration of all VideoManagers in vm_list to NUM_FRAMES frames. - for vm in vm_list: vm.set_duration(duration=base_timecode+NUM_FRAMES) - # (FOR TESTING PURPOSES ONLY) Manually override _cap_list with the - # duration-limited VideoManager objects in vm_list - video_manager._cap_list = vm_list - - try: - for vm in vm_list: vm.start() - video_manager.start() - assert video_manager.get_current_timecode() == base_timecode - - curr_time = video_manager.get_base_timecode() - while True: - ret_val, frame_image = video_manager.read() - if not ret_val: - break - assert frame_image.shape[0] > 0 - curr_time += 1 - assert curr_time == base_timecode + ((NUM_FRAMES+1) * NUM_VIDEOS) - - finally: - # Will release the VideoManagers in vm_list as well. - video_manager.release() - diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py new file mode 100644 index 00000000..d7e90336 --- /dev/null +++ b/tests/test_video_stream.py @@ -0,0 +1,412 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# 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 scenedetect.video_stream Tests + +This file includes unit tests for the scenedetect.video_stream module, as well as the video +backends implemented in scenedetect.backends. These tests enforce a consistent interface across +all supported backends, and verify that they are functionally equivalent where possible. +""" + +import os.path +import typing as ty +from dataclasses import dataclass + +import numpy +import pytest + +from scenedetect.backends import VideoStreamAv, VideoStreamMoviePy +from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.video_stream import SeekError, VideoStream + +# Accuracy a framerate is checked to for testing purposes. +FRAMERATE_TOLERANCE = 0.001 + +# Accuracy a time in milliseconds is checked to for testing purposes. +TIME_TOLERANCE_MS = 0.1 + +# Accuracy a pixel aspect ratio is checked to for testing purposes. +PIXEL_ASPECT_RATIO_TOLERANCE = 0.001 + +# Filter for warnings we ignore from VideoStreamMoviePy (warnings come from FFMPEG_VideoReader). +# The warning occurs when reading the last frame, which VideoStreamMoviePy handles gracefully. +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") + assert frame_a.shape == frame_b.shape + num_pixels = frame_a.shape[0] * frame_a.shape[1] + return numpy.sum(numpy.abs(frame_b - frame_a)) / num_pixels + + +# TODO: Reduce code duplication here and in `conftest.py` +def get_absolute_path(relative_path: str) -> str: + """Returns the absolute path to a (relative) path of a file that + should exist within the tests/ directory. + + Throws FileNotFoundError if the file could not be found. + """ + abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError( + 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 +""" + ) + return abs_path + + +@dataclass +class VideoParameters: + """Properties for each input a VideoStream is tested against.""" + + path: str + height: int + width: int + frame_rate: float + total_frames: int + aspect_ratio: float + + +# 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]: + """Fixture for parameters of all videos.""" + return [ + VideoParameters( + path=get_absolute_path("resources/testvideo.mp4"), + width=1280, + height=720, + frame_rate=29.97, + total_frames=720, + aspect_ratio=1.0, + ), + VideoParameters( + path=get_absolute_path("resources/goldeneye.mp4"), + width=1280, + height=544, + frame_rate=23.976, + total_frames=1980, + aspect_ratio=1.0, + ), + VideoParameters( + path=get_absolute_path("resources/issue-195-aspect-ratio.mp4"), + width=704, + height=576, + frame_rate=25.0, + total_frames=628, + aspect_ratio=1.4545454545, + ), + ] + + +_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", _VS_TYPES), + pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), +] + + +@pytest.mark.parametrize("test_video", get_test_video_params()) +class TestVideoStream: + """Fixture for tests which run against different input videos.""" + + 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 = auto_close(vs_type(test_video.path)) + assert stream.frame_size == (test_video.width, test_video.height) + assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) + assert stream.duration is not None + assert stream.duration.frame_num == test_video.total_frames + file_name = os.path.basename(test_video.path) + last_dot_pos = file_name.rfind(".") + assert stream.name == file_name[:last_dot_pos] + assert stream.aspect_ratio == pytest.approx( + test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE + ) + + def test_read( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Validate basic `read` functionality.""" + stream = auto_close(vs_type(test_video.path)) + frame = stream.read() + assert isinstance(frame, numpy.ndarray) + # For now hard-code 3 channels/pixel for each test video + assert frame.shape == (test_video.height, test_video.width, 3) + assert stream.frame_number == 1 + + def test_read_no_decode( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Validate invoking `read` with `decode` set to False.""" + stream = auto_close(vs_type(test_video.path)) + assert stream.read(decode=False) is True + assert stream.frame_number == 1 + + def test_time_invariants( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Validate the `frame_number`, `position`, and `position_ms` properties.""" + 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 + assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) + # Read the first frame (frame number 1). + assert stream.read() is not False + assert stream.frame_number == 1 + # The `position`/`position_ms` properties represent the presentation time, so they + # should still be zero for the first frame. + assert stream.position == stream.base_timecode + assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) + # Test that the invariants hold for the first few frames. + for i in range(2, 10): + assert stream.read() is not False + assert stream.frame_number == i + assert stream.position == stream.base_timecode + (i - 1) + assert stream.position_ms == pytest.approx( + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS + ) + + def test_reset( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Test `reset()` functions as expected.""" + 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() + assert stream.frame_number == 10 + stream.reset() + assert stream.frame_number == 0 + assert stream.position == 0 + assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) + + def test_seek( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Validate `seek()` functionality with different offset types.""" + stream = auto_close(vs_type(test_video.path)) + + # Seek to a given frame number (int). + stream.seek(200) + assert stream.frame_number == 200 + assert stream.position == stream.base_timecode + 199 + assert stream.position_ms == pytest.approx( + 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS + ) + stream.read() + assert stream.frame_number == 201 + assert stream.position == stream.base_timecode + 200 + assert stream.position_ms == pytest.approx( + 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS + ) + + # Seek to a time in seconds (float). + stream.seek(2.0) + assert stream.frame_number == round(stream.frame_rate * 2.0) + # FrameTimecode is currently one "behind" the frame_number since it + # starts counting from zero. This should eventually be changed. + assert stream.position == (stream.base_timecode + 2.0) - 1 + assert stream.position_ms == pytest.approx( + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate + ) + stream.read() + assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) + assert stream.position == stream.base_timecode + 2.0 + assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) + + # Seek to a FrameTimecode. + stream.seek(stream.base_timecode + 2.0) + assert stream.frame_number == round(stream.frame_rate * 2.0) + # FrameTimecode is currently one "behind" the frame_number since it + # starts counting from zero. This should eventually be changed. + assert stream.position == (stream.base_timecode + 2.0) - 1 + assert stream.position_ms == pytest.approx( + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate + ) + stream.read() + assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) + assert stream.position == stream.base_timecode + 2.0 + assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) + + def test_seek_start( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Validate behaviour of `seek()` at the start of a video.""" + 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 + assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) + # Seeking to frame 0 (or time 0) is equivalent to seeking "before" the first frame. + stream.seek(0) + assert stream.frame_number == 0 + assert stream.position == stream.base_timecode + assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) + # Ensure invariants hold for the first few frames. + for i in range(1, 10): + assert stream.read() is not False + assert stream.frame_number == i + assert stream.position == stream.base_timecode + (i - 1) + assert stream.position_ms == pytest.approx( + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS + ) + stream.seek(0) + assert stream.frame_number == 0 + assert stream.position == stream.base_timecode + assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) + + # Seek to the first frame (1) instead of the start (0) and verify the invariants. + stream.seek(1) + assert stream.frame_number == 1 + # Position and position_ms represent the presentation time, and thus are still zero. + assert stream.position == stream.base_timecode + assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) + stream.read() + assert stream.frame_number == 2 + stream = auto_close(vs_type(test_video.path)) + + 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 = 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: + pass + # 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_past_eof( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Validate calling `seek()` to offset past end of video.""" + 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). + try: + stream.seek(2**32) + except SeekError: + 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() 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: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): + """Test `seek()` throws correct exception when specifying in invalid seek value.""" + stream = auto_close(vs_type(test_video.path)) + + with pytest.raises(ValueError): + stream.seek(-1) + + with pytest.raises(ValueError): + stream.seek(-0.1) + + +# +# Tests which run against a specific inputs. +# + + +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_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 + + +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 new file mode 100644 index 00000000..3c97a978 --- /dev/null +++ b/website/mkdocs.yml @@ -0,0 +1,46 @@ +# PySceneDetect Website (https://www.scenedetect.com) +# 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" +docs_dir: "pages" +site_dir: "build" +repo_url: https://github.com/Breakthrough/PySceneDetect +edit_uri: 'blob/main/website/pages/' +repo_name: "PySceneDetect on Github" +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 +# Google Analytics is injected manually via overrides/main.html (the top-level +# `google_analytics` option was deprecated and removed from MkDocs). + +nav: +- 'PySceneDetect': + - 'Home': 'index.md' + - 'Features': 'features.md' + - 'Download': 'download.md' + - 'Changelog': 'changelog.md' +- 'Reference:': + - 'Documentation': 'docs.md' + - 'Command-Line': 'cli.md' + - 'Python API': 'api.md' + - 'Benchmarks': 'benchmarks.md' +- 'Support:': + - 'FAQ': 'faq.md' + - 'Bugs & Contributing': 'contributing.md' + - 'Development & Support': 'supporting.md' +- 'Resources': + - 'Similar Projects': 'similar.md' + - 'Research & Literature': 'literature.md' + - 'License & Copyright': 'copyright.md' + +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 new file mode 100644 index 00000000..f1413372 --- /dev/null +++ b/website/overrides/main.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block site_name %} +{% if page.is_index %} + +{% else %} + +{% endif %} + + +{% endblock %} + +{% block extrahead %} + +{% endblock %} diff --git a/website/pages/api.md b/website/pages/api.md new file mode 100644 index 00000000..009a6aac --- /dev/null +++ b/website/pages/api.md @@ -0,0 +1,81 @@ + +# API Reference + +The Python API is documented using Sphinx and [can be found here](docs.md). + +# Scene Detection Algorithms + +This page discusses the scene detection methods/algorithms available for use in PySceneDetect, including details describing the operation of the detection method, as well as relevant command-line arguments and recommended values. + +## Content-Aware Detector + +The content-aware scene detector (`detect-content`) detects [jump cuts](https://en.wikipedia.org/wiki/Jump_cut) in the input video. This is typically what people think of as "cuts" between scenes in a movie - given two adjacent frames, do they belong to the same scene? The content-aware scene detector finds areas where the *difference* between two subsequent frames exceeds the threshold value that is set (a good value to start with is `--threshold 27`). + +Internally, this detector functions by converting the colorspace of each decoded frame from [RGB](https://en.wikipedia.org/wiki/RGB_color_space) into [HSV](https://en.wikipedia.org/wiki/HSL_and_HSV). It then takes the average difference across all channels (or optionally just the *value* channel) from frame to frame. When this exceeds a set threshold, a scene change is triggered. + +`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](https://www.scenedetect.com/docs/latest/cli/detectors.html#detect-content) for details. + +## Adaptive Content Detector + +The adaptive content detector (`detect-adaptive`) compares the difference in content between adjacent frames similar to `detect-content` but instead using a rolling average of adjacent frame changes. This helps mitigate false detections where there is fast camera motion. + +## Threshold Detector + +The threshold-based scene detector (`detect-threshold`) is how most traditional scene detection methods work (e.g. the `ffmpeg blackframe` filter), by comparing the intensity/brightness of the current frame with a set threshold, and triggering a scene cut/break when this value crosses the threshold. In PySceneDetect, this value is computed by averaging the R, G, and B values for every pixel in the frame, yielding a single floating point number representing the average pixel value (from 0.0 to 255.0). + +## 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. + +## Perceptual Hash Detector + +The perceptual hash detector (`detect-hash`) calculates a hash for a frame and compares that hash to the previous frame's hash. If the hashes differ by more than the defined threshold, then a scene change is recorded. The hashing algorithm used for this detector is an implementation of `phash` from the [imagehash](https://github.com/JohannesBuchner/imagehash) library. In practice, this detector works similarly to `detect-content` in that it picks up large differences between adjacent frames. One important note is that the hashing algorithm converts the frames to grayscale, so this detector is insensitive to changes in colors if the brightness remains constant. In general, this algorithm is very computationally efficient compared to `detect-content` or `detect-adaptive`, especially if downscaling is not used. See [here](https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html) for an overview of how a perceptual hashing algorithm can be used for detecting similarity (or otherwise) of images and a visual depiction of the algorithm. + + +# Creating New Detection Algorithms + +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 +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 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, 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://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. + +`process_frame(...)` is called for each frame in sequence, passing the following arguments: + +- `frame_num`: the number of the current frame being processed +- `frame_img`: frame returned video file or stream (accessible as NumPy array) +- `frame_metrics`: dictionary for memoizing results of detection algorithm calculations for quicker subsequent analyses (if possible) +- `scene_list`: List containing the frame numbers where all scene cuts/breaks occur in the video. + +`post_process(...)` is called **after** the final frame has been processed, to allow for any stored scene cuts to be written *if required* (e.g. in the case of the `ThresholdDetector`). + +You may also want to look into the implementation of current detectors to understand how frame metrics are saved/loaded to/from a [`StatsManager`](https://www.scenedetect.com/docs/latest/api/stats_manager.html) for caching and allowing values to be written to a stats file for users to graph and find trends in to tweak detector options. Also see the documentation for the [`SceneManager`](https://www.scenedetect.com/docs/latest/api/scene_manager.html) for details. + 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 new file mode 100644 index 00000000..334fb876 --- /dev/null +++ b/website/pages/changelog.md @@ -0,0 +1,787 @@ + +# 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 + +### 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 + + - [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: + +comparison of new detector scores + +Feedback on the new detection methods and their default values is most welcome. Thanks to everyone who contributed for their help and support! + +#### Changelog + + - [feature] New detectors: + - `detect-hist` / `HistogramDetector` [#295](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + - `detect-hash` / `HashDetector` [#290](https://github.com/Breakthrough/PySceneDetect/pull/290) + - [feature] Add flash suppression filter for `detect-content` / `ContentDetector` (enabled by default) [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) + - Reduces number of cuts generated during strobing or flashing effects + - Can be configured using `--filter-mode` option + - `--filter-mode = merge` (new default) merges consecutive scenes shorter than `min-scene-len` + - `--filter-mode = suppress` (previous default) disables generating new scenes until `min-scene-len` has passed + - [feature] Add more templates for `save-images` filename customization: `$TIMECODE`, `$FRAME_NUMBER`, `$TIMESTAMP_MS` (thanks @Veldhoen0) [#395](https://github.com/Breakthrough/PySceneDetect/pull/395) + - [bugfix] Remove extraneous console output when using `--drop-short-scenes` + - [bugfix] Fix scene lengths being smaller than `min-scene-len` when using `detect-adaptive` / `AdaptiveDetector` with large values of `--frame-window` + - [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.3 (March 9, 2024) + +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:** + + - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + - [bugfix] Correct `--duration` and `--end` for presentation time when specified as frame numbers [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) + - [bugfix] Progress bar now has correct frame accounting when `--duration` or `--end` are set [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) + - [bugfix] Only allow `load-scenes` to be specified once, and disallow with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) + - [bugfix] Disallow `-s`/`--start` being larger than `-e`/`--end` for the `time` command + - [bugfix] Fix `detect-adaptive` not respecting `--min-scene-len` for the first scene + - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) + - [general] Several changes to `[list-scenes]` config file options: + - Add `display-scenes` and `display-cuts` options to control output + - Add `cut-format` to control formatting of cut points [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) + - Valid values: `frames`, `timecode`, `seconds` + - [general] Increase progress bar indent to improve visibility and visual alignment + - [improvement] The `s` suffix for setting timecode values in seconds is no longer required (values without decimal places are still interpreted as frame numbers) + - [improvement] `load-scenes` now skips detection, generating output much faster [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) (thanks @wjs018 for the initial implementation) + +**API Changes:** + + - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) + - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) + - [bugfix] Ensure correct string conversion behavior for `FrameTimecode` when rounding is enabled [#354](https://github.com/Breakthrough/PySceneDetect/issues/354) + - [bugfix] Fix `AdaptiveDetector` not respecting `min_scene_len` for the first scene + - [feature] Add `output_dir` argument to `split_video_ffmpeg` and `split_video_mkvmerge` functions to set output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) + - [feature] Add `formatter` argument to `split_video_ffmpeg` to allow formatting filenames via callback [#359](https://github.com/ + Breakthrough/PySceneDetect/issues/359) + - [general] The `frame_img` argument to `SceneDetector.process_frame()` is now required + - [general] Remove `TimecodeValue` from `scenedetect.frame_timecode` (use `typing.Union[int, float, str]`) +- [general] Remove `MotionDetector` and `scenedetect.detectors.motion_detector` module (will be reintroduced after `SceneDetector` interface is stable) + - [improvement] `scenedetect.stats_manager` module improvements: + - The `StatsManager.register_metrics()` method no longer throws any exceptions + - Add `StatsManager.metric_keys` property to query registered metric keys + - Deprecate `FrameMetricRegistered` and `FrameMetricNotRegistered` exceptions (no longer used) + - [improvement] When converting strings representing seconds to `FrameTimecode`, the `s` suffix is now optional, and whitespace is ignored (note that values without decimal places are still interpreted as frame numbers) + - [improvement] The `VideoCaptureAdapter` in `scenedetect.backends.opencv` now attempts to report duration if known + + +### 0.6.2 (July 23, 2023) + +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:** + + - [feature] Add [`load-scenes` command](https://www.scenedetect.com/docs/0.6.2/cli.html#load-scenes) to load cuts from `list-scenes` CSV output [#235](https://github.com/Breakthrough/PySceneDetect/issues/235) + - [feature] Use `detect-adaptive` by default if a detector is not specified [#329](https://github.com/Breakthrough/PySceneDetect/issues/329) + - Default detector can be set by [config file](https://www.scenedetect.com/docs/latest/cli/config_file.html) with the `default-detector` option under `[global]` + - [bugfix] Fix `-d`/`--duration` and `-e`/`--end` options of `time` command consuming one extra frame [#307](https://github.com/Breakthrough/PySceneDetect/issues/307) + - [bugfix] Fix incorrect end timecode for final scene when last frame of video is a new scene [#307](https://github.com/Breakthrough/PySceneDetect/issues/307) + - [bugfix] Expand `$VIDEO_NAME` before creating output directory for `-f`/`--filename` option of `split-video`, now allows absolute paths + - [general] Rename `ThresholdDetector` (`detect-threshold`) metric `delta_rgb` metric to `average_rgb` + - [general] `-l`/`--logfile` always produces debug logs now + - [general] Remove `-a`/`--all` flag from `scenedetect version` command, now prints all information by default (can still call `scenedetect` for version number alone) + - [general] Add `-h`/`--help` options globally and for each command + - [general] Remove `all` option from `scenedetect help` command (can now call `scenedetect help` for full reference) + +**General:** + + - [feature] Add ability to specify method (floor/ceiling) when creating [`ThresholdDetector`](https://www.scenedetect.com/docs/0.6.2/api/detectors.html#scenedetect.detectors.threshold_detector.ThresholdDetector), allows fade to white detection [#143](https://github.com/Breakthrough/PySceneDetect/issues/143) + - [general] Minimum supported Python version is now **Python 3.7** + - [general] Add support for PyAV 10.0 [#292](https://github.com/Breakthrough/PySceneDetect/issues/292) + - [general] Use platformdirs package instead of appdirs [#309](https://github.com/Breakthrough/PySceneDetect/issues/309) + - [bugfix] Fix `end_time` always consuming one extra frame [#307](https://github.com/Breakthrough/PySceneDetect/issues/307) + - [bugfix] Fix incorrect end timecode for last scene when `start_in_scene` is `True` or the final scene contains a single frame [#307](https://github.com/Breakthrough/PySceneDetect/issues/307) + - [bugfix] Fix MoviePy read next frame [#320](https://github.com/Breakthrough/PySceneDetect/issues/320) + - [bugfix] Template replacement when generating output now allows lower-case letters to be used as separators in addition to other characters + - [api] Make some public functions/methods private (prefixed with `_`): + - `get_aspect_ratio` function in `scenedetect.backends.opencv` + - `mean_pixel_distance` and `estimated_kernel_size` functions in `scenedetect.detectors.content_detector` + - `compute_frame_average` function in `scenedetect.detectors.threshold_detector` + - `scenedetect.cli` and `scenedetect.thirdparty` modules + - [api] Remove `compute_downscale_factor` in `scenedetect.video_stream` (use `scenedetect.scene_manager.compute_downscale_factor` instead) + - [dist] Updated dependencies in Windows distributions: ffmpeg 6.0, PyAV 10, OpenCV 4.8, removed mkvmerge + +#### Project Updates + + - Website and documentation is now hosted on Github Pages, documentation can be found at [scenedetect.com/docs](https://www.scenedetect.com/docs) + - Windows and Linux builds are now done on Github Actions, add OSX builds as well + - Build matrix has been updated to support Python 3.7 through 3.11 for all operating systems for Python distributions + - Windows portable builds have been moved to Github Actions, signed builds/installer is still done on Appveyor + - Windows distributions no longer include mkvmerge (can still [download for Windows here](https://mkvtoolnix.download/downloads.html#windows)) + + +### 0.6.1 (November 28, 2022) + +Includes [MoviePy support](https://github.com/Zulko/moviepy), edge detection capability for fast cuts, and several enhancements/bugfixes. + +#### Changelog + +**Command-Line Changes:** + + - [feature] Add `moviepy` backend wrapping the MoviePy package, uses `ffmpeg` binary on the system for video decoding + - [feature] Edge detection can now be enabled with `detect-content` and `detect-adaptive` to improve accuracy in some cases, especially under lighting changes, see [new `-w`/`--weights` option](http://scenedetect.com/projects/Manual/en/latest/cli/detectors.html#detect-content) for more information + - A good starting point is to place 100% weight on the change in a frame's hue, 50% on saturation change, 100% on luma (brightness) change, and 25% on change in edges, with a threshold of 32: + `detect-adaptive -w 1.0 0.5 1.0 0.25` + - Edge differences are typically larger than other components, so you may need to increase `-t`/`--threshold` higher when increasing the edge weight (the last component) with `detect-content, for example: + `detect-content -w 1.0 0.5 1.0 0.25 -t 32` + - May be enabled by default in the future once it has been more thoroughly tested, further improvements for `detect-content` are being investigated as well (e.g. motion compensation, flash suppression) + - Short-form of `detect-content` option `--frame-window` has been changed from `-w` to `-f` to accommodate this change + - [enhancement] Progress bar now displays number of detections while processing, no longer conflicts with log message output + - [enhancement] When using ffmpeg to split videos, `-map 0` has been added to the default arguments so other audio tracks are also included when present ([#271](https://github.com/Breakthrough/PySceneDetect/issues/271)) + - [enhancement] Add `-a` flag to `version` command to print more information about versions of dependencies/tools being used + - [enhancement] The resizing method used used for frame downscaling or resizing can now be set using [a config file](http://scenedetect.com/projects/Manual/en/latest/cli/config_file.html), see `[global]` option `downscale-method` and `[save-images]` option `scale-method` + - [other] Linear interpolation is now used as the default downscaling method (previously was nearest neighbor) for improved edge detection accuracy + - [other] Add `-c`/`--min-content-val` argument to `detect-adaptive`, deprecate `-d`/`--min-delta-hsv` + +**General:** + + - [general] Recommend `detect-adaptive` over `detect-content` + - [feature] Add new backend `VideoStreamMoviePy` using the MoviePy package` + - [feature] Add edge detection to `ContentDetector` and `AdaptiveDetector` ([#35](https://github.com/Breakthrough/PySceneDetect/issues/35)) + - Add ability to specify content score weights of hue, saturation, luma, and edge differences between frames + - Default remains as `1.0, 1.0, 1.0, 0.0` so there is no change in behavior + - Kernel size used for improving edge overlap can also be customized + - [feature] `AdaptiveDetector` no longer requires a `StatsManager` and can now be used with `frame_skip` ([#283](https://github.com/Breakthrough/PySceneDetect/issues/283)) + - [bugfix] Fix `scenedetect.detect()` throwing `TypeError` when specifying `stats_file_path` + - [bugfix] Fix off-by-one error in end event timecode when `end_time` was set (reported end time was always one extra frame) + - [bugfix] Fix a named argument that was incorrect ([#299](https://github.com/Breakthrough/PySceneDetect/issues/299)) + - [enhancement] Add optional `start_time`, `end_time`, and `start_in_scene` arguments to `scenedetect.detect()` ([#282](https://github.com/Breakthrough/PySceneDetect/issues/282)) + - [enhancement] Add `-map 0` option to default arguments of `split_video_ffmpeg` to include all audio tracks by default ([#271](https://github.com/Breakthrough/PySceneDetect/issues/271)) + - [docs] Add example for [using a callback](http://scenedetect.com/projects/Manual/en/v0.6.1/api/scene_manager.html#usage) ([#273](https://github.com/Breakthrough/PySceneDetect/issues/273)) + - [enhancement] Add new `VideoCaptureAdapter` to make existing `cv2.VideoCapture` objects compatible with a `SceneManager` ([#276](https://github.com/Breakthrough/PySceneDetect/issues/276)) + - Primary use case is for handling input devices/webcams and gstreamer pipes, [see updated examples](http://scenedetect.com/projects/Manual/en/latest/api/backends.html#devices-cameras-pipes) + - Files, image sequences, and network streams/URLs should continue to use `VideoStreamCv2` + - [api] The `SceneManager` methods `get_cut_list()` and `get_event_list()` are deprecated, along with the `base_timecode` argument + - [api] The `base_timecode` argument of `get_scenes_from_cuts()` in `scenedetect.stats_manager` is deprecated (the signature of this function has been changed accordingly) + - [api] Rename `AdaptiveDetector` constructor parameter `min_delta_hsv` to `min_content_val + - [general] The default `crf` for `split_video_ffmpeg` has been changed from 21 to 22 to match command line default + - [enhancement] Add `interpolation` property to `SceneManager` to allow setting method of frame downscaling, use linear interpolation by default (previously nearest neighbor) + - [enhancement] Add `interpolation` argument to `save_images` to allow setting image resize method (default remains bicubic) + +### 0.6 (May 29, 2022) + +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. + +Both the Windows installer and portable distributions now include signed executables. Many thanks to SignPath, AppVeyor, and AdvancedInstaller for their support. + +#### Changelog + +**Overview:** + + * Major performance improvements on multicore systems + * [Configuration file support](http://scenedetect.com/projects/Manual/en/latest/cli/config_file.html) via command line option or user settings folder + * Support for multiple video backends, PyAV is now supported in addition to OpenCV + * Breaking API changes to `VideoManager` (replaced with `VideoStream`), `StatsManager`, and `save_images()` + * See the [Migration Guide](https://scenedetect.com/projects/Manual/en/latest/api/migration_guide.html) for details on how to update from v0.5.x + * A backwards compatibility layer has been added to prevent most applications from breaking, will be removed in a future release + * Support for Python 2.7 has been dropped, minimum supported Python version is 3.6 + * Support for OpenCV 2.x has been dropped, minimum OpenCV version is 3.x + * Windows binaries are now signed, thanks [SignPath.io](https://signpath.io/) (certificate by [SignPath Foundation](https://signpath.org/)) + +**Command-Line Changes:** + + * Configuration files are now supported, [see documentation for details](http://scenedetect.com/projects/Manual/en/latest/cli/config_file.html) + * Can specify config file path with `-c`/`--config`, or create a `scenedetect.cfg` file in your user config folder + * Frame numbers are now 1-based, aligning with most other tools (e.g. `ffmpeg`) and video editors ([#265](https://github.com/Breakthrough/PySceneDetect/issues/265)) + * Start/end *frame numbers* of adjacent scenes no longer overlap ([#264](https://github.com/Breakthrough/PySceneDetect/issues/265)) + * End/duration timecodes still include the frame's presentation time + * Add `--merge-last-scene` option to merge last scene if shorter than `--min-scene-len` + * Add `-b`/`--backend` option to use a specific video decoding backend + * Supported backends are `opencv` and `pyav` + * Run `scenedetect help` to see a list of backends available on the current system + * Both backends are included with Windows builds + * `split-video` command: + * `-c`/`--copy` now uses `ffmpeg` instead of `mkvmerge` ([#77](https://github.com/Breakthrough/PySceneDetect/issues/77), [#236](https://github.com/Breakthrough/PySceneDetect/issues/236)) + * Add `-m`/`--mkvmerge` flag to use `mkvmerge` instead of `ffmpeg` ([#77](https://github.com/Breakthrough/PySceneDetect/issues/77)) + * Long name for `-a` has been changed to `--args` (from `--override-args`) + * `detect-adaptive` command: + * `--drop-short-scenes` now works properly with `detect-adaptive` + * `detect-content` command: + * Default threshold `-t`/`--threshold` lowered to 27 to be more sensitive to shot changes ([#246](https://github.com/Breakthrough/PySceneDetect/issues/246)) + * Add override for global `-m`/`--min-scene-len` option + * `detect-threshold` command: + * Remove `-p`/`--min-percent` and `-b`/`--block-size` options + * Add override for global `-m`/`--min-scene-len` option + * `save-images` command now works when `-i`/`--input` is an image sequences + * Default backend (OpenCV) is more robust to video decoder failures + * `-i`/`--input` may no longer be specified multiple times, if required use an external tool (e.g. `ffmpeg`, `mkvmerge`) to perform concatenation before processing + * `-s`/`--stats` no longer loads existing statistics and will overwrite any existing files + * `-l`/`--logfile` now respects `-o`/`--output` + * `-v`/`--verbosity` now takes precedence over `-q`/`--quiet` + +**API Changes:** + + * New `detect()` function performs scene detection on a video path, [see example here](http://scenedetect.com/projects/Manual/en/latest/api.html#quickstart) + * New `open_video()` function to handle video input, [see example here](http://scenedetect.com/projects/Manual/en/latest/api.html#example) + * `split_video_ffmpeg()` and `split_video_mkvmerge()` now take a single path as input + * `save_images()` no longer accepts `downscale_factor` + * Use `scale` or `height`/`width` arguments to resize images + * New `VideoStream` replaces `VideoManager` ([#213](https://github.com/Breakthrough/PySceneDetect/issues/213)) + * Supports both OpenCV (`VideoStreamCv2`) and PyAV (`VideoStreamAv`) + * Improves video seeking invariants, especially around defining what frames 0 and 1 mean for different time properties (`frame_number` is 1-based whereas `position` is 0-based to align with PTS) + * See `test_time_invariants` in `tests/test_video_stream.py` as a reference of specific behaviours + * Changes to `SceneManager`: + * `detect_scenes()` now performs video decoding in a background thread, improving performance on most systems + * `SceneManager` is now responsible for frame downscaling via the `downscale`/`auto_downscale` properties + * `detect_scenes()` no longer shows a progress bar by default, set `show_progress=True` to restore the previous behaviour + * `clear()` now clears detectors, as they may be stateful + * `get_scene_list()` now returns an empty list if there are no detected cuts, specify `start_in_scene=True` for previous behavior (one scene spanning the entire input) + * Changes to `StatsManager`: + * `save_to_csv()` now accepts a path or an open file handle + * `base_timecode` argument has been removed from `save_to_csv()` + * `load_from_csv()` is now deprecated and will be removed in v1.0 + * Changes to `FrameTimecode`: + * Use rounding instead of truncation when calculating frame numbers to fix incorrect round-trip conversions and improve accuracy ([#268](https://github.com/Breakthrough/PySceneDetect/issues/268)) + * Fix `previous_frame()` generating negative frame numbers in some cases + * `FrameTimecode` objects can now perform arithmetic with formatted strings, e.g. `'HH:MM:SS.nnn'` + * Merged constants `MAX_FPS_DELTA` and `MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT` in `scenedetect.frame_timecode` into new `MAX_FPS_DELTA` constant + * `video_manager` parameter has been removed from the `AdaptiveDetector` constructor + * `split_video_ffmpeg` and `split_video_mkvmerge` function arguments have been renamed and defaults updated: + * `suppress_output` is now `show_output`, default is `False` + * `hide_progress` is now `show_progress`, default is `False` + * `block_size` argument has been removed from the `ThresholdDetector` constructor + * `calculate_frame_score` method of `ContentDetector` has been renamed to `_calculate_frame_score`, use new module-level function of the same name instead + * `get_aspect_ratio` has been removed from `scenedetect.platform` (use the `aspect_ratio` property of a `VideoStream` instead) + * Backwards compatibility with v0.5 to avoid breaking most applications on release while still allowing performance improvements + +#### Python Distribution Changes + + * *v0.6.0.3* - Fix missing package description + * *v0.6.0.2* - Improve error messaging when OpenCV is not installed + * *v0.6.0.1* - Fix original v0.6 release requiring `av` to run the `scenedetect` command + +#### Known Issues + + * URL inputs are not supported by the `save-images` or `split-video` commands + * Variable framerate videos (VFR) are not fully supported, and will yield incorrect timestamps ([#168](https://github.com/Breakthrough/PySceneDetect/issues/168)) + * The `detect-threshold` option `-l`/`--add-last-scene` cannot be disabled + * Due to a switch from EXE to MSI for the Windows installer, you may have to uninstall older versions first before installing v0.6 + + +---------------------------------------------------------------- + + +## PySceneDetect 0.5 + +### 0.5.6.1 (October 11, 2021) + + * Fix crash when using `detect-content` or `detect-adaptive` with latest version of OpenCV (thanks @bilde2910) + + +### 0.5.6 (August 15, 2021) + + * **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` + * Removed the `-p`/`--min-percent` option from `detect-threshold` + * Add new option `-l`/`--luma-only` to `detect-content`/`detect-adaptive` to only consider brightness channel (useful for greyscale videos) + +#### Changelog + + * [feature] New adaptive content detector algorithm `detect-adaptive` ([#153](https://github.com/Breakthrough/PySceneDetect/issues/153), thanks @scarwire and @wjs018) + * [feature] Images generated with the `save-images` command (`scene_manager.save_images()` function in the Python API) can now be scaled or resized ([#160](https://github.com/Breakthrough/PySceneDetect/issues/160) and [PR #203](https://github.com/Breakthrough/PySceneDetect/pull/203), thanks @wjs018) + * Images can be resized by a constant scaling factory using `-s`/`--scale` (e.g. `--scale 0.5` shrinks the height/width by half) + * Images can be resized to a specified height (`-h`/`--height`) and/or width (`-w`/`--width`), in pixels; if only one is specified, the aspect ratio of the original video is kept + * [api] Calling `seek()` on a `VideoManager` will now respect the end time if set + * [api] The `split_video_` functions now return the exit code of invoking `ffmpeg` or `mkvmerge` ([#209](https://github.com/Breakthrough/PySceneDetect/issues/209), thanks @AdrienLF) + * [api] Removed the `min_percent` argument from `ThresholdDetector` as was not providing any performance benefit for the majority of use cases ([#178](https://github.com/Breakthrough/PySceneDetect/issues/178)) + * [bugfix] The `detect-threshold` command now works properly with a statsfile ([#211](https://github.com/Breakthrough/PySceneDetect/issues/211), thanks @jeremymeyers) + * [bugfix] Fixed crash due to unhandled `TypeError` exception when using non-PyPI OpenCV packages from certain Linux distributions ([#220](https://github.com/Breakthrough/PySceneDetect/issues/220)) + * [bugfix] A warning is now displayed for videos which may not be decoded correctly, esp. VP9 ([#86](https://github.com/Breakthrough/PySceneDetect/issues/86), thanks @wjs018) + * [api] A named logger is now used for both API and CLI logging instead of the root logger ([#205](https://github.com/Breakthrough/PySceneDetect/issues/205)) + +#### Known Issues + + * Variable framerate videos (VFR) are not fully supported, and will yield incorrect timestamps ([#168](https://github.com/Breakthrough/PySceneDetect/issues/168)) + * The `-l`/`--add-last-scene` option in `detect-threshold` cannot be disabled + * Image sequences or URL inputs are not supported by the `save-images` or `split-video` commands (in v0.6 `save-images` works with image sequences) + * Due to the use of truncation for frame number calculation, FrameTimecode objects may be off-by-one when constructed using a float value ([#268](https://github.com/Breakthrough/PySceneDetect/issues/268), fixed in v0.6) + + +### 0.5.5 (January 17, 2021) + + * 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` + * Removed first row from statsfiles so it is a valid CSV file + * The progress bar now correctly resizes when the terminal is resized + * Image sequences and URLs are now supported for input via the CLI/API + * Images exported using the `save-images` command are now resized to match the display aspect ratio + * A new flag `-s`/`--skip-cuts` has been added to the `list-scenes` command to allow standardized processing + * The functionality of `save-images` is now accessible via the Python API through the `save_images()` function in `scenedetect.scene_manager` + * Under the `save-images` command, renamed `--image-frame-margin` to `--frame-margin`, added short option `-m`, and increased the default value from 0 to 1 due to instances of the last frame of a video being occasionally missed (set `-m 0` to restore original behaviour) + +#### Changelog + + * [bugfix] Allow image sequences and URLs to be used as inputs ([#152](https://github.com/Breakthrough/PySceneDetect/issues/171) and [#188](https://github.com/Breakthrough/PySceneDetect/issues/188)) + * [bugfix] Pixel aspect ratio is now applied when using `save-images` ([#195](https://github.com/Breakthrough/PySceneDetect/issues/195)) + * [cli] Renamed `--image-frame-margin` to `--frame-margin` in `save-images` command, added short option `-m` as alias + * [bugfix] Fix `save-images` command not saving the last frame by modifying seeking, as well as increasing default of `--frame-margin` from 0 to 1 + * [cli] Make `--min-scene-len` a global option rather than per-detector ([#131](https://github.com/Breakthrough/PySceneDetect/issues/131), thanks @tonycpsu) + * [feature] Added `--drop-short-scenes` option to remove all scenes smaller than `--min-scene-len`, instead of merging them + * [cli] Add `-s`/`--skip-cuts` option to `list-scenes` command to allow outputting a scene list CSV file as compliant with RFC 4180 ([#136](https://github.com/Breakthrough/PySceneDetect/issues/136)) + * [enhancement] Removed first row from statsfile to comply with RFC 4180, includes backwards compatibility so existing statsfiles can still be loaded ([#136](https://github.com/Breakthrough/PySceneDetect/issues/136)) + * [api] Add argument `include_cut_list` to `write_scene_list` method in `SceneManager` to support [#136](https://github.com/Breakthrough/PySceneDetect/issues/136) + * [api] Removed unused argument base_timecode from `StatsManager.load_from_csv()` method + * [api] Make the `base_timecode` argument optional on the `SceneManager` methods `get_scene_list()`, `get_cut_list()`, and `get_event_list()` ([#173](https://github.com/Breakthrough/PySceneDetect/issues/173)) + * [api] Support for live video stream callbacks by adding new `callback` argument to the `detect_scenes()` method of `SceneManager` ([#5](https://github.com/Breakthrough/PySceneDetect/issues/5), thanks @mhashim6) + * [bugfix] Fix unhandled exception causing improper error message when a video fails to load on non-Windows platforms ([#192](https://github.com/Breakthrough/PySceneDetect/issues/192)) + * [enhancement] Enabled dynamic resizing for progress bar ([#193](https://github.com/Breakthrough/PySceneDetect/issues/193)) + * [enhancement] Always output version number via logger to assist with debugging ([#171](https://github.com/Breakthrough/PySceneDetect/issues/171)) + * [bugfix] Resolve RuntimeWarning when running as module ([#181](https://github.com/Breakthrough/PySceneDetect/issues/181)) + * [api] Add `save_images()` function to `scenedetect.scene_manager` module which exposes the same functionality as the CLI `save-images` command ([#88](https://github.com/Breakthrough/PySceneDetect/issues/88)) + * [api] Removed `close_captures()` and `release_captures()` functions from `scenedetect.video_manager` module + +#### Known Issues + + * Certain non-PyPI OpenCV packages may cause a crash with the message `TypeError: isinstance() arg 2 must be a type or tuple of types` - as a workaround, install the Python OpenCV package by running `pip install scenedetect[opencv]` ([#220](https://github.com/Breakthrough/PySceneDetect/issues/220)) + * Image sequences or URL inputs are not supported by the `save-images` or `split-video` commands + * Variable framerate videos (VFR) are not fully supported, and will yield incorrect timestamps ([#168](https://github.com/Breakthrough/PySceneDetect/issues/168)) + + +### 0.5.4 (September 14, 2020) + + * 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 + * Fix crash when using `save-images` command under Python 2.7 + * Support for Python 3.3 and 3.4 has been deprecated (see below) + +#### Changelog + + * [bugfix] fix `detect-threshold` crash when using statsfile ([#122](https://github.com/Breakthrough/PySceneDetect/issues/122)) + * [bugfix] fix `save-images` command under Python 2.7 ([#174](https://github.com/Breakthrough/PySceneDetect/issues/174), thanks @santiagodemierre) + * [bugfix] gracefully exit and show link to FAQ when number of scenes is too large to split with mkvmerge on Windows (see [#164](https://github.com/Breakthrough/PySceneDetect/issues/164, thanks @alexboydray) + * [enhancement] Improved seeking performance, greatly improves performance of the `time` and `save-images` commands ([#98](https://github.com/Breakthrough/PySceneDetect/issues/98) and [PR #163](https://github.com/Breakthrough/PySceneDetect/pull/163) - thanks @obroomhall) + * [enhancement] improve `detect-threshold` performance when min-percent is less than 50% + * [bugfix] Fixed issue where video loading would fail silently due to multiple audio tracks ([#179](https://github.com/Breakthrough/PySceneDetect/issues/179)) + * [general] Made `tqdm` a regular requirement and not an extra ([#180](https://github.com/Breakthrough/PySceneDetect/issues/180)) + * [general] Support for Python 3.3 and 3.4 has been deprecated. Newer builds may still work on these Python versions, but future releases are not tested against these versions. This decision was made as part of [#180](https://github.com/Breakthrough/PySceneDetect/issues/180) + +#### Known Issues + + * Variable framerate videos are not supported properly currently (#168), a warning may be added in the next release to indicate when a VFR video is detected, until this can be properly resolved ([#168](https://github.com/Breakthrough/PySceneDetect/issues/168)) + + +### 0.5.3 (July 12, 2020) + + * 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) + * Improved timestamp accuracy when using `split-video` command to further reduce instances of duplicated or off-by-one frame issues + * Fixed application crash when using the `-l`/`--logfile` argument + +#### Changelog + + * [bugfix] Changed default audio codec from 'copy' to 'aac' when splitting scenes with `ffmpeg` to reduce frequency of frames from next scene showing up at the end of the current one when split using `ffmpeg` (see [#93](https://github.com/Breakthrough/PySceneDetect/issues/93), [#159](https://github.com/Breakthrough/PySceneDetect/issues/159), and [PR #166](https://github.com/Breakthrough/PySceneDetect/pull/166) - thank you everyone for your assistance, especially joshcoales, amvscenes, jelias, and typoman). If this still occurs, please provide any information you can by [filing a new issue on Github](https://github.com/Breakthrough/PySceneDetect/issues/new/choose). + * [enhancement] `video_splitter` module now has completed documentation + * [bugfix] improve timestamp accuracy using the `split-video` command due to timecode formatting + * [bugfix] fix crash when supplying `-l`/`--logfile` argument (see [#169](https://github.com/Breakthrough/PySceneDetect/issues/169), thanks @typoman) + +#### Known Issues + + * Seeking through long videos is inefficient, causing the `time` and `save-images` command to take a long time to run. This will be resolved in the next release (see [#98](https://github.com/Breakthrough/PySceneDetect/issues/98)) + * The `save-images` command causes PySceneDetect to crash under Python 2.7 (see [#174](https://github.com/Breakthrough/PySceneDetect/issues/174)) + * Using `detect-threshold` with a statsfile causes PySceneDetect to crash (see [#122](https://github.com/Breakthrough/PySceneDetect/issues/122)) + * Variable framerate videos are not supported properly currently (#168), a warning may be added in the next release to indicate when a VFR video is detected, until this can be properly resolved ([#168](https://github.com/Breakthrough/PySceneDetect/issues/168)) + * Videos with multiple audio tracks may not work correctly, see [this comment on #179](https://github.com/Breakthrough/PySceneDetect/issues/179#issuecomment-685252441) for a workaround using `ffmpeg` or `mkvmerge` + + +### 0.5.2 (March 29, 2020) + + * [enhancement] `--min-duration` now accepts a timecode in addition to frame number ([#128](https://github.com/Breakthrough/PySceneDetect/pull/128), thanks @tonycpsu) + * [feature] Add `--image-frame-margin` option to `save-images` command to ignore a number of frames at the start/end of a scene ([#129](https://github.com/Breakthrough/PySceneDetect/pull/129), thanks @tonycpsu) + * [bugfix] `--min-scene-len` option was not respected by first scene ([#105](https://github.com/Breakthrough/PySceneDetect/issues/105), thanks @charlesvestal) + * [bugfix] Splitting videos with an analyzed duration only splits within analyzed area ([#106](https://github.com/Breakthrough/PySceneDetect/issues/106), thanks @charlesvestal) + * [bugfix] Improper start timecode applied to the `split-video` command when using `ffmpeg` ([#93](https://github.com/Breakthrough/PySceneDetect/issues/93), thanks @typoman) + * [bugfix] Added links and filename sanitation to html output ([#139](https://github.com/Breakthrough/PySceneDetect/issues/139) and [#140](https://github.com/Breakthrough/PySceneDetect/issues/140), thanks @wjs018) + * [bugfix] UnboundLocalError in `detect_scenes` when `frame_skip` is larger than 0 ([#126](https://github.com/Breakthrough/PySceneDetect/issues/126), thanks @twostarxx) + + +### 0.5.1.1 (August 3, 2019) + + * minor re-release of v0.5.1 which updates the setup.py file to return OpenCV as an optional dependency + * to install from pip now with all dependencies: `pip install scenedetect[opencv,progress_bar]` + * to install only PySceneDetect: `pip install scenedetect` (separate OpenCV installation required) + * the release notes of v0.5.1 have been modified to include the prior command + * no change to PySceneDetect program version + * [feature] add `get_duration` method to VideoManager ([#109](https://github.com/Breakthrough/PySceneDetect/issues/109), thanks @arianaa30) + + +### 0.5.1 (July 20, 2019) + + * [feature] Add new `export-html` command to the CLI (thanks [@wjs018](https://github.com/Breakthrough/PySceneDetect/pull/104)) + * [bugfix] VideoManager read function failed on multiple videos (thanks [@ivan23kor](https://github.com/Breakthrough/PySceneDetect/pull/107)) + * [bugfix] Fix crash when no scenes are detected ([#79](https://github.com/Breakthrough/PySceneDetect/issues/79), thanks @raj6996) + * [bugfix] Fixed OpenCV not getting installed due to missing dependency ([#73](https://github.com/Breakthrough/PySceneDetect/issues/73)) + * [enhance] When no scenes are detected, the whole video is now returned instead of nothing (thanks [@piercus](https://github.com/Breakthrough/PySceneDetect/pull/89)) + * Removed Windows installer due to binary packages now being available, and to streamline the release process (see [#102](https://github.com/Breakthrough/PySceneDetect/issues/102) for more information). When you type `pip install scenedetect[opencv,progress_bar]`, all dependencies will be installed. + + +### 0.5 (August 31, 2018) + + * **major** release, includes stable Python API with examples and updated documentation + * numerous changes to command-line interface with addition of sub-commands (see [the new manual](http://manual.scenedetect.com) for updated usage information) + * [feature] videos are now split using `ffmpeg` by default, resulting in frame-perfect cuts (can still use `mkvmerge` by specifying the `-c`/`--copy` argument to the `split-video` command) + * [enhance] image filename numbers are now consistent with those of split video scenes (PR #39, thanks [@e271828-](https://github.com/Breakthrough/PySceneDetect/pull/39)) + * [enhance] 5-10% improvement in processing performance due to reduced memory copy operations (PR #40, thanks [@elcombato](https://github.com/Breakthrough/PySceneDetect/pull/40)) + * [enhance] updated exception handling to raise proper standard exceptions (PR #37, thanks [@talkain](https://github.com/Breakthrough/PySceneDetect/pull/37)) + * several fixes to the documentation, including improper dates and outdated CLI arguments (PR #26 and #, thanks [@elcombato](https://github.com/Breakthrough/PySceneDetect/pull/26), and [@colelawrence](https://github.com/Breakthrough/PySceneDetect/pull/33)) + * *numerous* other PRs and issues/bug reports that have been fixed - there are too many to list individually here, so I want to extend a big thank you to **everyone** who contributed to making this release better + * [enhance] add Sphinx-generated API documentation (available at: http://manual.scenedetect.com) + * [project] move from BSD 2-clause to 3-clause license + + +---------------------------------------------------------------- + + +## PySceneDetect 0.4 + +### 0.4 (January 14, 2017) + + * major release, includes integrated scene splitting via mkvmerge, changes meaning of `-o` / `--output` option + * [feature] specifying `-o OUTPUT_FILE.mkv` will now automatically split the input video, generating a new video clip for each detected scene in sequence, starting with `OUTPUT_FILE-001.mkv` + * [enhance] CSV file output is now specified with the `-co` / `--csv-output` option (*note, used to be `-o` in versions of PySceneDetect < 0.4*) + + +---------------------------------------------------------------- + + +## PySceneDetect 0.3-beta + +### 0.3.6 (January 12, 2017) + + * [enhance] performance improvement when using `--frameskip` option (thanks [@marcelluzs](https://github.com/marcelluzs)) + * [internal] moved application state and shared objects to a consistent interface (the `SceneManager` object) to greatly reduce the number of required arguments for certain API functions + * [enhance] added installer for Windows builds (64-bit only currently) + + +### 0.3.5 (August 2, 2016) + + * [enhance] initial release of portable build for Windows (64-bit only), including all dependencies + * [bugfix] fix unrelated exception thrown when video could not be loaded (thanks [@marcelluzs](https://github.com/marcelluzs)) + * [internal] fix variable name typo in API documentation + + +### 0.3.4 (February 8, 2016) + + * [enhance] add scene length, in seconds, to output file (`-o`) for easier integration with `ffmpeg`/`libav` + * [enhance] improved performance of content detection mode by caching intermediate HSV frames in memory (approx. 2x faster) + * [enhance] show timecode values in terminal when using extended output (`-l`) + * [feature] add fade bias option (`-fb` / `--fade-bias`) to command line (threshold mode only) + + +### 0.3.3 (January 27, 2016) + + * [bugfix] output scenes are now correctly written to specified output file when using -o flag (fixes #11) + * [bugfix] fix indexing exception when using multiple scene detectors and outputting statistics + * [internal] distribute package on PyPI, version move from beta to stable + * [internal] add function to convert frame number to formatted timecode + * [internal] move file and statistic output to Python `csv` module + + +### 0.3.2-beta (January 26, 2016) + + * [feature] added `-si` / `--save-images` flag to enable saving the first and last frames of each detected scene as an image, saved in the current working directory with the original video filename as the output prefix + * [feature] added command line options for setting start and end times for processing (`-st` and `-et`) + * [feature] added command line option to specify maximum duration to process (`-dt`, overrides `-et`) + + +### 0.3.1-beta (January 23, 2016) + + * [feature] added downscaling/subsampling option (`-df` / `--downscale-factor`) to improve performance on higher resolution videos + * [feature] added frameskip option (`-fs` / `--frame-skip`) to improve performance on high framerate videos, at expense of frame accuracy and possible inaccurate scene cut prediction + * [enhance] added setup.py to allow for one-line installation (just run `python setup.py install` after downloading and extracting PySceneDetect) + * [internal] additional API functions to remove requirement on passing OpenCV video objects, and allow just a file path instead + + +### 0.3-beta (January 8, 2016) + + * major release, includes improved detection algorithms and complete internal code refactor + * [feature] content-aware scene detection using HSV-colourspace based algorithm (use `-d content`) + * [enhance] added CLI flags to allow user changes to more algorithm properties + * [internal] re-implemented threshold-based scene detection algorithm under new interface + * [internal] major code refactor including standard detection algorithm interface and API + * [internal] remove statistics mode until update to new detection mode interface + + +---------------------------------------------------------------- + + +## PySceneDetect 0.2-alpha + +### 0.2.4-alpha (December 22, 2015) + * [bugfix] updated OpenCV compatibility with self-reported version on some Linux distributions + + +### 0.2.3-alpha (August 7, 2015) + * [bugfix] updated PySceneDetect to work with latest OpenCV module (ver > 3.0) + * [bugfix] added compatibility/legacy code for older versions of OpenCV + * [feature] statsfile generation includes expanded frame metrics + + +### 0.2.2-alpha (November 25, 2014) + + * [feature] added statistics mode for generating frame-by-frame analysis (-s / --statsfile flag) + * [bugfix] fixed improper timecode conversion + + +### 0.2.1-alpha (November 16, 2014) + + * [enhance] proper timecode format (HH:MM:SS.nnnnn) + * [enhance] one-line of CSV timecodes added for easy splitting with external tool + + +### 0.2-alpha (June 9, 2014) + + * [enhance] now provides discrete scene list (in addition to fades) + * [feature] ability to output to file (-o / --output flag) + + +---------------------------------------------------------------- + + +## PySceneDetect 0.1-alpha + +### 0.1-alpha (June 8, 2014) + + * first public release + * [feature] threshold-based fade in/out detection + + +---------------------------------------------------------------- + + +Development +========================================================== + +## PySceneDetect 0.7.2 (TBD) + + - [general] The `scenedetect-core` package introduced in 0.7.1 has been discontinued, and its only release (0.7.1) yanked from PyPI: pip cannot safely support multiple packages that install the same module files, and restructuring the existing packages around a shared core would break in-place upgrades. Existing `scenedetect-core` installs keep working but will not receive updates; continue to install `scenedetect` or `scenedetect-headless` as usual. + - [improvement] `HistogramDetector` (`detect-hist`) default `threshold` changed from 0.05 to 0.20 and default `bins` from 256 to 128, calibrated from the [benchmark sweep](https://www.scenedetect.com/benchmarks/) for significantly better accuracy. Default output for this detector will change [#559](https://github.com/Breakthrough/PySceneDetect/issues/559) + - [improvement] `HashDetector` (`detect-hash`) default `threshold` changed from 0.395 to 0.35 and default `size` from 16 to 8, calibrated from the [benchmark sweep](https://www.scenedetect.com/benchmarks/) for better accuracy. Default output for this detector will change, including the statsfile metric key (now `hash_dist [size=8 lowpass=2]`) [#559](https://github.com/Breakthrough/PySceneDetect/issues/559) diff --git a/website/pages/cli.md b/website/pages/cli.md new file mode 100644 index 00000000..78e583fc --- /dev/null +++ b/website/pages/cli.md @@ -0,0 +1,265 @@ + +# PySceneDetect CLI + +See [the documentation](../docs/latest/) for a complete reference to the `scenedetect` command with more examples. + +## Quickstart + +Split input video on each fast cut using `ffmpeg`: + +```bash +scenedetect -i video.mp4 split-video +``` + +Save some frames from each cut: + +```bash +scenedetect -i video.mp4 save-images +``` + +Skip the first 10 seconds of the input video: + +```bash +scenedetect -i video.mp4 time -s 10s +``` + +## Example + +As a concrete example to become familiar with PySceneDetect, let's use the following short clip from the James Bond movie, GoldenEye (Copyright © 1995 MGM): + +[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/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 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: + +```bash +scenedetect --input goldeneye.mp4 detect-adaptive list-scenes save-images +``` + +Running the above command, in the working directory, you should see a file `goldeneye-Scenes.csv`, as well as individual frames for the start/middle/end of each scene starting with `goldeneye-Scene-001-01.jpg`. The results should appear as follows: + + +| Scene # | Start Time | Preview | +| ------------ | ------------- | ------------- | +| 1 | 00:00:00.000 | | +| 2 | 00:00:03.754 | | +| 3 | 00:00:08.759 | | +| 4 | 00:00:10.802 | | +| 5 | 00:00:15.599 | | +| 6 | 00:00:27.110 | | +| 7 | 00:00:34.117 | | +| 8 | 00:00:36.536 | | +| ... | ... | ... | +| 18 | 00:01:06.316 | | +| 19 | 00:01:10.779 | | +| 20 | 00:01:18.036 | | +| 21 | 00:01:19.913 | | +| 22 | 00:01:21.999 | | + + +## Splitting Video into Clips + +The `split-video` command can be used to automatically split the input video using `ffmpeg` or `mkvmerge`. For example: + +```bash +scenedetect -i goldeneye.mp4 split-video +``` + +Type `scenedetect split-video --help` for a full list of options which can be specified for video splitting, including high quality mode (`-hq/--high-quality`) or copy mode (`-c/--copy`). + +You can also specify `-h` / `--high-quality` to produces near lossless results, or `-p`/`--preset` and `-crf`/`--rate-factor` (call `scenedetect help split-video` for details). If either `-c`/`--copy` or `-m`/`--mkvmerge` is specified, codec copying mode is used, at the expense of frame accurate cuts. Optionally, you can also specify the x264 `-p`/`--preset` and `-crf`/`--rate-factor` (see `scenedetect split-video --help` for details). + + +## Detection Methods + +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](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. + + +### Content-Aware Detection + +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). + + +### 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. + +```bash +scenedetect -i my_video.mp4 -s my_video.stats.mp4 detect-threshold +``` + +```bash +scenedetect -i my_video.mp4 -s my_video.stats.mp4 detect-threshold -t 20 +``` + +Using values for threshold less than `8` may cause problems with some videos, especially those encoded at lower bitrates or with limited dynamic range. + +The optimal threshold can be determined by generating a statsfile (`-s`), opening it with a spreadsheet editor (e.g. Excel), and examining the `delta_rgb` column. These values represent the average intensity of the pixels for that particular frame (taken by averaging the R, G, and B values over the whole frame). The threshold value should be set so that the average intensity of most frames in content scenes lie above the threshold value, and scenes where scene changes/breaks occur should fall *under* the threshold value (thus triggering a scene change). + + +### Adaptive Detection + +The `detect-adaptive` mode compares each frame's score as calculated by `detect-content` with its neighbors. This score is what forms the `adaptive_ratio` metric in the statsfile. You can also configure the amount of neighboring frames via the `frame-window` option, as well as the minimum change in `content_val` score using `min-content-val`. + + +## Detection Parameters + +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: + +```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 + +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. + +## Saving Image Previews of Detected Scenes + +PySceneDetect can automatically save the beginning and ending frame of each detected scene by using the `save-images` command. If present, the first and last frames of each scene will be saved in the current working directory, using the filename of the input video. + +Files marked `00` represent the starting frame of the scene, and those marked `01` represent the last frame (e.g. `testvideo.mp4.Scene-4-01.jpg`). By default, two images are generated. + +Coming soon: If more are specified via the `-n` flag, they will start from `00` (the first frame) and be evenly spaced throughout the scene until the last frame, which will be numbered `N-1`. + + +## Improving Processing Speed/Performance + +The following arguments are global program options, and need to be applied before any commands (e.g. `detect-content`, `list-scenes`). They can be used to achieve performance gains for some source material with a variable loss of accuracy. + +Assuming the input video is of a high enough resolution, a significant performance gain can be achieved by sub-sampling (down-scaling) the input image by a specific integer factor (2x, 3x, 4x, 5x...). This is applied automatically to some degree based on the input video size, but can be overridden manually with the `-d` / `--downscale` option. + +This factor represents how many pixels are "skipped" in both the x- and y- directions, effectively down-scaling the image (using nearest-neighbor sampling) by the factor specified (the new resolution being `W/factor x H/factor` if the old resolution is `W x H`). + +Another method that can be used to gain a performance boost is frame skipping. This method, however, severely reduces frame-accurate scene cuts, so it should only be used with high FPS material (ideally > 60 FPS), at low values (try not to exceed a value of `1` or `2` if using `-fs` / `--frame-skip`), in cases where this is acceptable. Using the frame skip option also disallows the use of a stats file, which offsets the speed gain if the same video needs to be processed multiple times (e.g. to determine the optimal threshold). + +The option still remains, however, for the set of cases where it is still required. For example, if we skip every other frame (e.g. using `--frame-skip 1`), the processing speed should roughly double. + +If set too large, enough frames may be skipped each time that the threshold is met during every iteration, continually triggering scene changes. This is because frame skipping essentially raises the threshold between frames in the same scene (making them more likely to appear as *cuts*) while not affecting the threshold between frames of different scenes. + +This makes the two harder to distinguish, and can cause additional false scene cuts to be detected. While this can be compensated for by raising the threshold value, this increases the probability of missing a real/true scene cut - thus, the use of the `-fs` / `--frame-skip` option is discouraged. + + +## Seeking, Duration, and Setting Start / Stop Times + +Specifying the `time` command allows control over what portion of the video PySceneDetect processes. The `time` command accepts three options: start time (`-s` / `-start`), end time (`-e` / `-end`), and duration (`-d` / `--duration`). Specifying both end time and duration is redundant, and in this case, duration overrides end time. Timecodes can be given in seconds (`100.0`), frames (no decimal place, `100`), or timecode as `HH:MM:SS[.nnn]` (`12:34:56.789`). + +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): + +```bash +scenedetect -i my_video.mp4 time --start 00:05:00 --end 00:06:30 +``` + +```bash +scenedetect -i my_video.mp4 time --start 300s --end 390s +``` + +```bash +scenedetect -i my_video.mp4 time --start 300s --duration 90s +``` + +```bash +scenedetect -i my_video.mp4 time --start 300s --duration 2700 +``` + +This demonstrates the different timecode formats, interchanging end time with duration and vice-versa, and precedence of setting duration over end time. + + +## Config File + +A configuration file path can be specified using the `-c`/`--config` argument. PySceneDetect also looks for a config file named `scenedetect.cfg` in one of the following locations: + + * Windows: + * `C:/Users/%USERNAME%/AppData/Local/PySceneDetect/scenedetect.cfg` + + * Linux: + * `~/.config/PySceneDetect/scenedetect.cfg` + * `$XDG_CONFIG_HOME/scenedetect.cfg` + + * Mac: + * `~/Library/Preferences/PySceneDetect/scenedetect.cfg` + +Run `scenedetect --help` to see the exact path on your system which will be used (it will be listed under the help text for the -c/--config option). You can [click here to download a `scenedetect.cfg` config file](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.4-release/scenedetect.cfg) to use as a template. Note that lines starting with a `#` are comments and will be ignored. The `scenedetect.cfg` template file is also available in the folder where PySceneDetect is installed. + +Specifying a config file path using -c/--config overrides the user config file. Specifying values on the command line will override those values in the config file. + +The syntax of a configuration file is: + +```ini +[command] +option_a = value +#comment +option_b = 1 +``` + +### Example + +```ini +[global] +default-detector = detect-content +min-scene-len = 0.8s + +[detect-content] +threshold = 32 +weights = 1.0 0.5 1.0 0.2 + +[split-video] +preset = slow +rate-factor = 17 +# Don't need to use quotes even if filename contains spaces +filename = $VIDEO_NAME-Clip-$SCENE_NUMBER + +[save-images] +format = jpeg +quality = 80 +num-images = 3 +``` + +See the `scenedetect.cfg` file in the location you installed PySceneDetect or [download it from Github](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.4-release/scenedetect.cfg) for a complete listing of all configuration options. + + +##   Video Splitting Requirements + +PySceneDetect can use either `ffmpeg` or `mkvmerge` to split videos automatically. + +By default, when specifying the `split-video` command, `ffmpeg` will be used to split the video. If the `-c`/`--copy` option is also set (e.g. `split-video --copy`), `mkvmerge` will be used to split the video instead. + + +### FFmpeg + +You can download `ffmpeg` from: [https://ffmpeg.org/download.html](https://ffmpeg.org/download.html) + +Note that Linux users should use a package manager (e.g. `sudo apt-get install ffmpeg`). Windows users may require additional steps in order for PySceneDetect to detect `ffmpeg` - see the section Manually Enabling `split-video` Support below for details. + + +### mkvmerge + +You can download and install `mkvmerge` as part of the mkvtoolnix package from: +[https://mkvtoolnix.download/downloads.html](https://mkvtoolnix.download/downloads.html) + +Note that Windows users should use the installer/setup, and Linux users should use their system package manager, otherwise PySceneDetect may not be able to find `mkvmerge`. If this is the case, see the section below to enable support for the `split-video --copy` command manually. + + +### Enabling `split-video` Support + +If PySceneDetect cannot find the respective tool installed on your system, you have three options: + + 1. Place the tool in the same location that PySceneDetect is installed (e.g. copy and paste mkvmerge.exe into the same place scenedetect.exe is located). This is the easiest solution for most users. + + 2. Add the directory where you installed ffmpeg/mkvmerge to your system's PATH environment variable, ensuring that you can use the ffmpeg/mkvmerge command from any terminal/command prompt. This is the best solution for advanced users. + + 3. Place the tool in a location already in your system's PATH variable (e.g. C:/Windows). This is not recommended, but may be the only solution on systems without administrative rights. + diff --git a/website/pages/contributing.md b/website/pages/contributing.md new file mode 100644 index 00000000..7e3f55d9 --- /dev/null +++ b/website/pages/contributing.md @@ -0,0 +1,48 @@ + +##   Bug Reports + +Bugs, issues, features, and improvements to PySceneDetect are handled through [the issue tracker on Github](https://github.com/Breakthrough/PySceneDetect/issues). If you run into any bugs using PySceneDetect, please [create a new issue](https://github.com/Breakthrough/PySceneDetect/issues/new/choose). + +Try to [find an existing issue](https://github.com/Breakthrough/PySceneDetect/issues?q=) before creating a new one, as there may be a workaround posted there. Additional information is also helpful for existing reports. + +##   Contributing to Development + +Development of PySceneDetect happens on [github.com/Breakthrough/PySceneDetect](https://github.com/Breakthrough/PySceneDetect). Pull requests are accepted and encouraged. Where possible, PRs should be submitted with a dedicated entry in [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=). Issues and features are typically grouped into version milestones. + +The following checklist covers the basics of pre-submission requirements: + + - Code passes all unit tests (run `pytest`) + - Code passes static analysis and formatting checks (`ruff check` and `ruff format`) + - Follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) + +Note that PySceneDetect is released under the BSD 3-Clause license, and submitted code should comply with this license (see [License & Copyright Information](copyright.md) for details). + +##   Features That Need Help + +The following is a "wishlist" of features which PySceneDetect eventually should have, but does not currently due to lack of resources. Anyone who is able to contribute in any capacity to these items is encouraged to do so by starting a dialogue by opening a new issue on Github as per above. + +### Flash Suppression + +Some detection methods struggle with bright flashes and fast camera movement. The detection pipeline has some filters in place to deal with these cases, but there are still drawbacks. We are actively seeking methods which can improve both performance and accuracy in these cases. + +### Automatic Thresholding + +The `detect-content` command requires a manual threshold to be set currently. Methods to use peak detection to dynamically determine when scene cuts occur would allow for the program to work with a much wider amount of material without requiring manual tuning, but would require statistical analysis. + +Ideally, this would be something like `-threshold=auto` as a default. + +### Dissolve Detection + +Depending on the length of the dissolve and parameters being used, detection accuracy for these types of cuts can vary widely. A method to improve accuracy with minimal performance loss is an open problem. + +### Advanced Strategies + +Research into detection methods and performance are ongoing. All contributions in this regard are most welcome. + +### GUI + +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 + +PySceneDetect currently is not localized for other languages. Anyone who can help improve how localization can be approached for development material is encouraged to contribute in any way possible. Whether it is the GUI program, the command line interface, or documentation, localization will allow PySceneDetect to be used by much more users in their native languages. \ No newline at end of file diff --git a/docs/copyright.md b/website/pages/copyright.md similarity index 64% rename from docs/copyright.md rename to website/pages/copyright.md index 9914178c..d522083c 100644 --- a/docs/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 > + < http://www.bcastell.com/projects/PySceneDetect > -Copyright (C) 2012-2018, Brandon Castellano. +Copyright (C) 2014, Brandon Castellano. All rights reserved. Redistribution and use in source and binary forms, with or without @@ -18,7 +18,7 @@ are met: 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials + disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its @@ -43,24 +43,32 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. This section contains links to the license agreements for all third-party software libraries used and distributed with PySceneDetect. You can find copies of all the relevant license agreements referenced below if you installed a copy of PySceneDetect by looking at the LICENSE files in the installation directory. +----------------------------------------------------------------------- + + +### click + + - Copyright (C) 2017, Armin Ronacher. + - URL: http://click.pocoo.org/license/ ### NumPy - Copyright (C) 2005-2016, NumPy Developers. - URL: http://www.numpy.org/license.html - ### OpenCV - Copyright (C) 2017, Itseez. - URL: http://opencv.org/license.html +### PyAV + - Copyright (C) 2017, Mike Boers and others + - URL: https://github.com/PyAV-Org/PyAV/blob/main/LICENSE.txt -### click - - - Copyright (C) 2017, Armin Ronacher. - - URL: http://click.pocoo.org/license/ +### simpletable + - Copyright (C) 2014-2019, Matheus Vieira Portela and others + - URL: https://github.com/matheusportela/simpletable/blob/master/LICENSE ### tqdm @@ -68,11 +76,7 @@ This section contains links to the license agreements for all third-party softwa - URL: https://raw.githubusercontent.com/tqdm/tqdm/master/LICENCE -### click - - - Copyright (C) 2004-2017, Holger Krekel and others. - - URL: https://docs.pytest.org/en/latest/license.html - +----------------------------------------------------------------------- ### FFmpeg and mkvmerge @@ -81,27 +85,6 @@ 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, or visit the -below URLs for details. In source distributions of PySceneDetect, -neither mkvmerge nor FFmpeg is not distributed, and requires manual -installation in order to allow automatic video splitting capability. -These programs can be obtained from following URLs (note that mkvmerge -is a part of the MKVToolNix package): - - FFmpeg: [ https://ffmpeg.org/download.html ] - mkvmerge: [ https://mkvtoolnix.download/downloads.html ] - -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. - - -### Python - -Additionally, certain Windows distributions may include a compiled -Python distribution. For license information regarding the distributed -version of Python, see the LICENSE files in the installation directory, -or visit the following URL: [ https://docs.python.org/3/license.html ] +Certain distributions of PySceneDetect may include ffmpeg. See the +thirdparty/LICENSE-FFMPEG file or visit [ https://ffmpeg.org ]. diff --git a/website/pages/docs.md b/website/pages/docs.md new file mode 100644 index 00000000..8840e94f --- /dev/null +++ b/website/pages/docs.md @@ -0,0 +1,21 @@ + +# Documentation + +## 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/) diff --git a/website/pages/download.md b/website/pages/download.md new file mode 100644 index 00000000..0dd1c64c --- /dev/null +++ b/website/pages/download.md @@ -0,0 +1,94 @@ + +# Download + +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.10 or higher. + + +## Install via pip       + +
+

Standard install (recommended):

+
pip install --upgrade scenedetect
+

Headless install (servers, no GUI libs):

+
pip install --upgrade scenedetect-headless
+
+ +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.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. + + +## Dependencies + +### Python 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` (any `opencv-python*` variant works) + - [Numpy](https://numpy.org/): `pip install numpy` + - [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: + + - [PyAV](https://pyav.org/): `pip install av` + +### Video Splitting Tools + +For video splitting support, you need to have one of the following tools available (included in Windows builds): + + - [ffmpeg](https://ffmpeg.org/download.html), required to split video files (`split-video` or `split-video -c/--copy`) + - [mkvmerge](https://mkvtoolnix.download/), part of mkvtoolnix, command-line tool, required to split video files in stream copy mode (`split-video -c/--copy` only) + +The `ffmpeg` and/or `mkvmerge` command must be available system wide (e.g. in a directory in `PATH`, so it can be used from any terminal/console by typing the command), or alternatively, placed in the same directory where PySceneDetect is installed. On Windows this is usually `C:\PythonXY\Scripts`, where `XY` is your Python version. For more information, [see the CLI documentation](cli.md). + +### Building OpenCV from Source + +If you have installed OpenCV using `pip`, you will need to uninstall it before installing a different version of OpenCV, or building and installing it from source. + +You can [click here](http://breakthrough.github.io/Installing-OpenCV/) for a quick guide (OpenCV + Numpy on Windows & Linux) on installing OpenCV/Numpy on [Windows (using pre-built binaries)](http://breakthrough.github.io/Installing-OpenCV/#installing-on-windows-pre-built-binaries) and [Linux (compiling from source)](http://breakthrough.github.io/Installing-OpenCV/#installing-on-linux-compiling-from-source). If the Python module that comes with OpenCV on Windows is incompatible with your system architecture or Python version, [see this page](http://www.lfd.uci.edu/~gohlke/pythonlibs/#opencv) to obtain a pre-compiled (unofficial) module. + +To ensure you have all the requirements installed, open a `python` interpreter, and ensure you can run `import numpy` and `import cv2` without any errors. + + +## Code Signing Policy + +This program uses free code signing provided by [SignPath.io](https://signpath.io?utm_source=foundation&utm_medium=website&utm_campaign=PySceneDetect), and a free code signing certificate by the [SignPath Foundation](https://signpath.org?utm_source=foundation&utm_medium=website&utm_campaign=PySceneDetect) diff --git a/website/pages/faq.md b/website/pages/faq.md new file mode 100644 index 00000000..0756fa33 --- /dev/null +++ b/website/pages/faq.md @@ -0,0 +1,56 @@ + + +##   Frequently Asked Questions + +#### How can I fix `ImportError: No module named cv2`? + +As of PySceneDetect 0.7, the OpenCV dependency is bundled with the install. The standard `scenedetect` package depends on `opencv-python`: + +```bash +pip install scenedetect +``` + +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: + +```bash +pip install scenedetect +pip install "opencv-contrib-python==$(pip show opencv-python | grep ^Version | cut -d' ' -f2)" +``` + +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](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`? + +This error occurs on Windows platforms specifically when the number of detected scenes is too large. This is because PySceneDetect internally invokes other commands, such as those used for the `split-video` command. + +You can get around this issue by simply invoking those tools manually, using a smaller sub-set of scenes (or splitting the scene list into multiple parts). You can obtain a comma-separated list of timecodes by using the `list-scenes` command. + +See [Issue #164](https://github.com/Breakthrough/PySceneDetect/issues/164) for details, or if you have any further questions. + +#### How can I fix the error `Failed to read any frames from video file`? + +Unfortunately, the underlying library used to perform video I/O was unable to open the file. Try using a different backend by installing PyAV (`pip install av`) and see if the problem persists. + +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`: + +```bash +ffmpeg -i input.mp4 -c copy -an output.mp4 +``` + +Or: + +```bash +mkvmerge -o output.mkv input.mp4 +``` diff --git a/website/pages/features.md b/website/pages/features.md new file mode 100644 index 00000000..ba2d6f4e --- /dev/null +++ b/website/pages/features.md @@ -0,0 +1,73 @@ + +## Overview + +
+

  Content-Aware Scene Detection

   Detects breaks in-between content, not only when the video fades to black (although a threshold mode is available as well for those cases). +
+ +
+

  Compatible With Many External Tools

   The detected scene boundaries/cuts can be exported in a variety of formats, with the default type (comma-separated HH:MM:SS.nnn values) being ready to copy-and-paste directly into other tools (such as ffmpeg, mkvmerge, etc...) for splitting and/or re-encoding the video. +
+ +
+

  Statistical Video Analysis

   Can output a spreadsheet-compatible file for analyzing trends in a particular video file, to determine the optimal threshold values to use with specific scene detection methods/algorithms. +
+ +
+

  Extendible and Embeddable

   Written in Python, and designed with an easy-to-use and extendable API, PySceneDetect is ideal for embedding into other programs, or to implement custom methods/algorithms of scene detection for specific applications (e.g. analyzing security camera footage). +
+ + +------------------------------------------------------------------------ + + +## Features + + - 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 + +PySceneDetect implements a variety of different detection algorithms which can be used independently or combined depending on the source material being analyzed. + + - **adaptive content scene detection** (`detect-adaptive`): uses rolling average of differences in HSL colorspace combined with thresholding to detect shot changes (fast cut) + - **content-aware scene detection** (`detect-content`): uses differences in HSL colorspace combined with filtering to detect shot changes (fast cut) + - **content-aware scene detection** (`detect-hash`): uses perceptual hashing to determine differences between frames to find shot changes (fast cut) + - **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, and [the benchmarks page](benchmarks.md) for how each detector scores on public shot-boundary-detection datasets. + +------------------------------------------------------------------------ + + +## Version Roadmap + +Future version roadmaps are now [tracked as milestones (link)](https://github.com/Breakthrough/PySceneDetect/milestones). Specific issues/features that are queued up for the very next release will have [the `backlog` tag](https://github.com/Breakthrough/PySceneDetect/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+backlog%22), and issues/features being worked on will have [the `status: in progress` tag](https://github.com/Breakthrough/PySceneDetect/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+in+progress%22). Also note that bug reports as well as additional feature requests can be submitted via [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues); read [the Bug Reports and Contributing page](contributing.md) for details. + + +### Planned Features + +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") + - suppression of short-length flashes/bursts of light [#35](https://github.com/Breakthrough/PySceneDetect/issues/35) + - histogram-based detection algorithm in HSV/HSL color space [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + - [perceptual hash](https://en.wikipedia.org/wiki/Perceptual_hashing) based scene detection ([prototype by @wjs018 in PR#290](https://github.com/Breakthrough/PySceneDetect/pull/290)) + - adaptive bias for fade in/out interpolation + - export scenes in chapter/XML format [#323](https://github.com/Breakthrough/PySceneDetect/issues/323) diff --git a/website/pages/img/0.6.4-score-comparison.png b/website/pages/img/0.6.4-score-comparison.png new file mode 100644 index 00000000..1b2c68b3 Binary files /dev/null and b/website/pages/img/0.6.4-score-comparison.png differ 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/goldeneye-stats.png b/website/pages/img/goldeneye-stats.png new file mode 100644 index 00000000..b8cc2276 Binary files /dev/null and b/website/pages/img/goldeneye-stats.png differ diff --git a/docs/img/params.png b/website/pages/img/params.png similarity index 100% rename from docs/img/params.png rename to website/pages/img/params.png diff --git a/website/pages/img/pyscenedetect_logo.png b/website/pages/img/pyscenedetect_logo.png new file mode 100644 index 00000000..1b8163e1 Binary files /dev/null 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 new file mode 100644 index 00000000..0634b34c Binary files /dev/null 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 new file mode 100644 index 00000000..8b2d88ed --- /dev/null +++ b/website/pages/index.md @@ -0,0 +1,40 @@ + + +PySceneDetect + +
+

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

+  Download        Changelog        Documentation        Getting Started +
+ +**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. + +

Quickstart

+ +Split video on each fast cut using [command line (more examples)](cli.md): + +```bash +scenedetect -i video.mp4 split-video +``` + +Split video on each fast cut using [Python API (docs)](docs.md): + +```python +from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg + +scene_list = detect("my_video.mp4", AdaptiveDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) +``` + + +

Examples and Use Cases

+ +Here are some of the things people are using PySceneDetect for: + + - splitting home videos or other source footage into individual scenes + - automated detection and removal of commercials from PVR-saved video sources + - processing and splitting surveillance camera footage + - statistical analysis of videos to find suitable "loops" for looping GIFs/cinemagraphs + - academic analysis of film and video (e.g. finding mean shot length) + +Of course, this is just a small slice of what you can do with PySceneDetect, so why not try it out for yourself! The timecode format used by default (`HH:MM:SS.nnnn`) is compatible with most popular video tools, so in most cases the output scene list from PySceneDetect can be directly copied and pasted into another tool of your choice (e.g. `ffmpeg`, `avconv` or the `mkvtoolnix` suite). 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/literature.md b/website/pages/literature.md new file mode 100644 index 00000000..8bce6ca8 --- /dev/null +++ b/website/pages/literature.md @@ -0,0 +1,29 @@ + +# PySceneDetect in Literature + +PySceneDetect is a useful tool for statistical analysis of video. Below are links to various research articles/papers which have either used PySceneDetect as a part of their analysis, or propose more accurate detection algorithms using the current implementation as a comparison. + + - [Panda-70M: Captioning 70M Videos with Multiple Cross-Modality Teachers](https://arxiv.org/abs/2402.19479) by Tsai-Shien Chen, Aliaksandr Siarohin, Willi Menapace, Ekaterina Deyneka, Hsiang-wei Chao, Byung Eun Jeon, Yuwei Fang, Hsin-Ying Lee, Jian Ren, Ming-Hsuan Yang, Sergey Tulyakov (2024) + + - [Stable Remaster: Bridging the Gap Between Old Content and New Displays](https://arxiv.org/pdf/2306.06803.pdf) by Nathan Paull, Shuvam Keshari, Yian Wong (2023) + + - [LoL-V2T: Large-Scale Esports Video Description Dataset](https://ieeexplore.ieee.org/abstract/document/9522986) by Tsunehiko Tanaka, Edgar Simo-Serra (2021) + + - [Online Detection of Action Start via Soft Computing for Smart City](https://ieeexplore.ieee.org/document/9099408) by Tian Wang, Yang Chen, Hongqiang Lv, Jing Teng, Hichem Snoussi, Fei Tao (2020) + + - [Thesis Project: Smart Shades and Cane for The Blind](https://www.linkedin.com/pulse/blind-people-dont-have-good-muhammad-hashim-1f/) by Muhammad Hashim (2020) + + - [Movienet: a movie multilayer network model using visual and textual semantic cues](https://appliednetsci.springeropen.com/articles/10.1007/s41109-019-0226-0) by Youssef Mourchid, Benjamin Renoust, Olivier Roupin, Lê Văn, Hocine Cherifi & Mohammed El Hassouni (2019) + + - [NLP-Enriched Automatic Video Segmentation](https://ieeexplore.ieee.org/document/8525880/) by Mohannad AlMousa, Rachid Benlamri, Richard Khoury (2018) + + - [Online Detection of Action Start in Untrimmed, Streaming Videos](https://arxiv.org/pdf/1802.06822) by Zheng Shou, Junting Pan, Jonathan Chan, Kazuyuki Miyazawa, Hassan Mansour, Anthony Vetro, Xavi Gir-i-Nieto, Shih-Fu Chang (2018) + + - [Story Understanding in Video Advertisements](https://arxiv.org/pdf/1807.11122) by Keren Ye, Kyle Buettner, Adriana Kovashka (2018) + +This list is only provided for academic and research purposes, and is far from an exhaustive source of the uses of PySceneDetect in literature. If you think a particular submission is relevant and should be added to this list, feel free to [raise an issue](https://github.com/Breakthrough/PySceneDetect/issues/new/choose) with your suggestion. Publicly available material is preferred, although not a requirement. + + +# Scene Detection Methodology + +You can find the source code for each scene detector in [the scenedetect/detectors folder](https://github.com/Breakthrough/PySceneDetect/tree/main/scenedetect/detectors). Also see [Issue #62: Reference of paper for the methods used](https://github.com/Breakthrough/PySceneDetect/issues/62) on Github for a further discussion on detection methodologies. You are more than welcome to propose any new ideas on the [issue tracker](https://github.com/Breakthrough/PySceneDetect/issues), or share a proof of concept using the Python API by creating a pull request. diff --git a/website/pages/similar.md b/website/pages/similar.md new file mode 100644 index 00000000..94b017cb --- /dev/null +++ b/website/pages/similar.md @@ -0,0 +1,13 @@ + +## Alternative and Related Programs + +The following is a list of programs or commands also performing scene cut analysis of some kind on video files. [Additions/contributions to this list are welcome](contributing.md). + + - [Scenecut Extractor](https://github.com/slhck/scenecut-extractor) - uses ffmpeg `select` filter + - ffmpeg `blackframe` filter ([thanks @tonycpsu](https://github.com/Breakthrough/PySceneDetect/issues/7)) - threshold mode only + - [Shotdetect](http://johmathe.name/shotdetect.html) - appears to be only for *NIX, content mode only + - [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) + diff --git a/website/pages/style.css b/website/pages/style.css new file mode 100644 index 00000000..8c59567a --- /dev/null +++ b/website/pages/style.css @@ -0,0 +1,99 @@ +.wy-side-nav-search { + display: block; + width: 300px; + padding: .809em; + margin-bottom: .809em; + z-index: 200; + background-color: #CCD7E2; + text-align: center; + color: #fcfcfc +} + +.wy-side-nav-search .wy-dropdown>a, +.wy-side-nav-search>a { + color:#3B3F47; + font-size:100%; + font-weight:700; + display:inline-block; + 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/pages/supporting.md b/website/pages/supporting.md new file mode 100644 index 00000000..a1d99b66 --- /dev/null +++ b/website/pages/supporting.md @@ -0,0 +1,32 @@ + +#   Supporting Development + +This page is dedicated to the various people, tools, technologies, and companies which support the development of PySceneDetect. This page has been created to give credit to the things which allow PySceneDetect to exist, and is not meant to imply any kind of endorsement. + + +##   Tools, Technologies, and Companies + +The development of PySceneDetect is supported by the following tools, technologies, and companies: + + - [Github](https://github.com/) - Git and website hosting, code review, issue tracking, Linux/Windows/OSX builds + - [AppVeyor](https://www.appveyor.com/) - Windows builds (portable + MSI) + - [AdvancedInstaller](https://www.advancedinstaller.com/) - MSI installer + - [SignPath](https://signpath.io/) - Code signing for Windows builds + +Special thanks to these companies for their support. + + +##   Community Contributions + +PySceneDetect is an open source project which anyone can freely contribute to (see [this page for various ways you can contribute](contributing.md)). You can view [the contribution graph on Github](https://github.com/Breakthrough/PySceneDetect/graphs/contributors) or [visit PySceneDetect at libraries.io](https://libraries.io/github/Breakthrough/PySceneDetect/contributors) or view the to see a list detailing the contributions people have made to the PySceneDetect project. + +In addition to those who have made a direct contribution in the form of a pull request, a special thank you to *everyone* who has submitted an issue, bug report, feature request, and/or pull request (see the links above for complete lists), as well as for those who continue to help the ongoing development of PySceneDetect. + +Your contributions continue to improve PySceneDetect, highlight the assets and talents of the FOSS community, and help to make the project's goal of being the most accurate scene detection program/library become a reality. Lastly, thank you all for your help, support, feedback, and direction on the project. + + +##   Donations + +Monetary donations are not accepted for the project, but [there are ways you can contribute](contributing.md) to the project. Furthermore, if you have resources, tools, or technologies which may benefit the development of PySceneDetect, please feel free to contact me via the issue tracker or [my website](http://bcastell.com/contact/). + +Many thanks to everyone who continually supports the development of the project.