diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index ea077de8c..000000000 --- a/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -[*.yml] -indent_size = 2 - -[Makefile] -indent_size = unset -indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..24b8f0829 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# POSIX-sensitive files: always LF +*.sh text eol=lf +Makefile text eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/build-bug-report.md b/.github/ISSUE_TEMPLATE/build-bug-report.md deleted file mode 100644 index 9f327e5de..000000000 --- a/.github/ISSUE_TEMPLATE/build-bug-report.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -name: Build bug report -about: Report on an issue while building or installing PyAV. -title: "FOO does not build." -labels: build -assignees: '' - ---- - -**IMPORTANT:** Be sure to replace all template sections {{ like this }} or your issue may be discarded. - - -## Overview - -{{ A clear and concise description of what the bug is. }} - - -## Expected behavior - -{{ A clear and concise description of what you expected to happen. }} - - -## Actual behavior - -{{ A clear and concise description of what actually happened. }} - -Build report: -``` -{{ Complete output of `python setup.py build`. Reports that do not show compiler commands will not be accepted (e.g. results from `pip install av`). }} -``` - - -## Investigation - -{{ What you did to isolate the problem. }} - - -## Reproduction - -{{ Steps to reproduce the behavior. }} - - -## Versions - -- OS: {{ e.g. macOS 10.13.6 }} -- PyAV runtime: -``` -{{ Complete output of `python -m av --version` if you can run it. }} -``` -- PyAV build: -``` -{{ Complete output of `python setup.py config --verbose`. }} -``` -- FFmpeg: -``` -{{ Complete output of `ffmpeg -version` }} -``` - - -## Research - -I have done the following: - -- [ ] Checked the [PyAV documentation](https://pyav.org/docs) -- [ ] Searched on [Google](https://www.google.com/search?q=pyav+how+do+I+foo) -- [ ] Searched on [Stack Overflow](https://stackoverflow.com/search?q=pyav) -- [ ] Looked through [old GitHub issues](https://github.com/PyAV-Org/PyAV/issues?&q=is%3Aissue) -- [ ] Asked on [PyAV Gitter](https://gitter.im/PyAV-Org) -- [ ] ... and waited 72 hours for a response. - - -## Additional context - -{{ Add any other context about the problem here. }} diff --git a/.github/ISSUE_TEMPLATE/ffmpeg-feature-request.md b/.github/ISSUE_TEMPLATE/ffmpeg-feature-request.md deleted file mode 100644 index 115c15b13..000000000 --- a/.github/ISSUE_TEMPLATE/ffmpeg-feature-request.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: FFmpeg feature request -about: Request a feature of FFmpeg be exposed or supported by PyAV. -title: "Allow FOO to BAR" -labels: enhancement -assignees: '' - ---- - -**IMPORTANT:** Be sure to replace all template sections {{ like this }} or your issue may be discarded. - - -## Overview - -{{ A clear and concise description of what the feature is. }} - - -## Existing FFmpeg API - -{{ Link to appropriate FFmpeg documentation, ideally the API doxygen files at https://ffmpeg.org/doxygen/trunk/ }} - - -## Expected PyAV API - -{{ A description of how you think PyAV should behave. }} - -Example: -``` -{{ An example of how you think PyAV should behave. }} -``` - - -## Investigation - -{{ What you did to isolate the problem. }} - - -## Reproduction - -{{ Steps to reproduce the behavior. If the problem is media specific, include a link to it. Only send media that you have the rights to. }} - - -## Versions - -- OS: {{ e.g. macOS 10.13.6 }} -- PyAV runtime: -``` -{{ Complete output of `python -m av --version`. If this command won't run, you are likely dealing with the build issue and should use the appropriate template. }} -``` -- PyAV build: -``` -{{ Complete output of `python setup.py config --verbose`. }} -``` -- FFmpeg: -``` -{{ Complete output of `ffmpeg -version` }} -``` - - -## Additional context - -{{ Add any other context about the problem here. }} diff --git a/.github/ISSUE_TEMPLATE/pyav-feature-request.md b/.github/ISSUE_TEMPLATE/pyav-feature-request.md deleted file mode 100644 index 7433cbfbe..000000000 --- a/.github/ISSUE_TEMPLATE/pyav-feature-request.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: PyAV feature request -about: Request a feature of PyAV that is not provided by FFmpeg. -title: "Allow FOO to BAR" -labels: enhancement -assignees: '' - ---- - -**IMPORTANT:** Be sure to replace all template sections {{ like this }} or your issue may be discarded. - - -## Overview - -{{ A clear and concise description of what the feature is. }} - - -## Desired Behavior - -{{ A description of how you think PyAV should behave. }} - - -## Example API - -``` -{{ An example of how you think PyAV should behave. }} -``` - - -## Additional context - -{{ Add any other context about the problem here. }} diff --git a/.github/ISSUE_TEMPLATE/runtime-bug-report.md b/.github/ISSUE_TEMPLATE/runtime-bug-report.md deleted file mode 100644 index b29b11266..000000000 --- a/.github/ISSUE_TEMPLATE/runtime-bug-report.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -name: Runtime bug report -about: Report on an issue while running PyAV. -title: "The FOO does not BAR." -labels: bug -assignees: '' - ---- - -**IMPORTANT:** Be sure to replace all template sections {{ like this }} or your issue may be discarded. - - -## Overview - -{{ A clear and concise description of what the bug is. }} - - -## Expected behavior - -{{ A clear and concise description of what you expected to happen. }} - - -## Actual behavior - -{{ A clear and concise description of what actually happened. }} - -Traceback: -``` -{{ Include complete tracebacks if there are any exceptions. }} -``` - - -## Investigation - -{{ What you did to isolate the problem. }} - - -## Reproduction - -{{ Steps to reproduce the behavior. If the problem is media specific, include a link to it. Only send media that you have the rights to. }} - - -## Versions - -- OS: {{ e.g. macOS 10.13.6 }} -- PyAV runtime: -``` -{{ Complete output of `python -m av --version`. If this command won't run, you are likely dealing with the build issue and should use the appropriate template. }} -``` -- PyAV build: -``` -{{ Complete output of `python setup.py config --verbose`. }} -``` -- FFmpeg: -``` -{{ Complete output of `ffmpeg -version` }} -``` - - -## Research - -I have done the following: - -- [ ] Checked the [PyAV documentation](https://pyav.org/docs) -- [ ] Searched on [Google](https://www.google.com/search?q=pyav+how+do+I+foo) -- [ ] Searched on [Stack Overflow](https://stackoverflow.com/search?q=pyav) -- [ ] Looked through [old GitHub issues](https://github.com/PyAV-Org/PyAV/issues?&q=is%3Aissue) -- [ ] Asked on [PyAV Gitter](https://gitter.im/PyAV-Org) -- [ ] ... and waited 72 hours for a response. - - -## Additional context - -{{ Add any other context about the problem here. }} diff --git a/.github/ISSUE_TEMPLATE/user-help.md b/.github/ISSUE_TEMPLATE/user-help.md deleted file mode 100644 index 62c3e5c16..000000000 --- a/.github/ISSUE_TEMPLATE/user-help.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: User help -about: Request help with using PyAV. -title: "How do I FOO?" -labels: 'user help' -assignees: '' - ---- - -**IMPORTANT:** Be sure to replace all template sections {{ like this }} or your issue may be discarded. - - -## Overview - -{{ A clear and concise description of your problem. }} - - -## Expected behavior - -{{ A clear and concise description of what you expected to happen. }} - - -## Actual behavior - -{{ A clear and concise description of what actually happened. }} - -Traceback: -``` -{{ Include complete tracebacks if there are any exceptions. }} -``` - - -## Investigation - -{{ What you tried so far to fix your problem. }} - - -## Research - -I have done the following: - -- [ ] Checked the [PyAV documentation](https://pyav.org/docs) -- [ ] Searched on [Google](https://www.google.com/search?q=pyav+how+do+I+foo) -- [ ] Searched on [Stack Overflow](https://stackoverflow.com/search?q=pyav) -- [ ] Looked through [old GitHub issues](https://github.com/PyAV-Org/PyAV/issues?&q=is%3Aissue) -- [ ] Asked on [PyAV Gitter](https://gitter.im/PyAV-Org) -- [ ] ... and waited 72 hours for a response. - - -## Additional context - -{{ Add any other context about the problem here. }} diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 000000000..9ff147c7d --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,201 @@ +name: smoke +on: + workflow_dispatch: + push: + branches: main + paths-ignore: + - '**.md' + - '**.rst' + - '**.txt' + - '.github/workflows/tests.yml' + pull_request: + branches: main + paths-ignore: + - '**.md' + - '**.rst' + - '**.txt' + - '.github/workflows/tests.yml' +jobs: + style: + runs-on: ubuntu-slim + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + - name: Linters + run: make lint + + nix: + name: "py-${{ matrix.config.python }} lib-${{ matrix.config.ffmpeg }} ${{matrix.config.os}}" + runs-on: ${{ matrix.config.os }} + strategy: + fail-fast: false + matrix: + config: + - {os: ubuntu-24.04, python: "3.14", ffmpeg: "8.1.2", extras: true} + - {os: ubuntu-24.04, python: "3.11", ffmpeg: "8.0.3"} + - {os: ubuntu-24.04, python: "3.13", ffmpeg: "8.1.2"} + - {os: macos-14, python: "3.12", ffmpeg: "8.0.3"} + - {os: macos-14, python: "3.14", ffmpeg: "8.1.2"} + + env: + PYAV_PYTHON: python${{ matrix.config.python }} + PYAV_LIBRARY: ffmpeg-${{ matrix.config.ffmpeg }} + + steps: + - uses: actions/checkout@v7 + name: Checkout + + - name: Python ${{ matrix.config.python }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.config.python }} + allow-prereleases: true + + - name: OS Packages + run: | + case ${{ matrix.config.os }} in + ubuntu-24.04) + sudo apt-get update + sudo apt-get install \ + autoconf \ + automake \ + build-essential \ + cmake \ + libtool \ + libx264-dev \ + nasm \ + pkg-config \ + zlib1g-dev + if [[ "${{ matrix.config.extras }}" ]]; then + sudo apt-get install doxygen + fi + ;; + macos-14) + brew install automake libtool opus x264 + ;; + esac + + - name: Pip and FFmpeg + run: | + . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} + scripts/build-deps + + - name: Build + run: | + [ "${{ runner.os }}" = "macOS" ] && export ARCHFLAGS="-arch $(uname -m)" + . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} + scripts/build + + - name: Test + run: | + . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} + python -m av --version # Assert it can import. + make test + + - name: Docs + if: matrix.config.extras + run: | + . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} + make -C docs html + + - name: Doctest + if: matrix.config.extras + run: | + . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} + make -C docs test + + - name: Examples + if: matrix.config.extras + run: | + . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} + scripts/test examples + + windows: + name: "py-${{ matrix.config.python }} lib-${{ matrix.config.ffmpeg }} ${{matrix.config.os}}" + runs-on: ${{ matrix.config.os }} + + strategy: + fail-fast: false + matrix: + config: + - {os: windows-latest, python: "3.11", ffmpeg: "latest"} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Conda + shell: bash + run: | + . $CONDA/etc/profile.d/conda.sh + conda config --set always_yes true + conda config --add channels conda-forge + conda config --add channels scientific-python-nightly-wheels + conda create -q -n pyav \ + numpy \ + pillow \ + pytest \ + python=${{ matrix.config.python }} \ + setuptools \ + cython + + - name: Build + shell: bash + run: | + . $CONDA/etc/profile.d/conda.sh + conda activate pyav + python scripts\\fetch-vendor.py --config-file scripts\\ffmpeg-${{ matrix.config.ffmpeg }}.json $CONDA_PREFIX\\Library + python setup.py build_ext --inplace --ffmpeg-dir=$CONDA_PREFIX\\Library + + - name: Test + shell: bash + run: | + . $CONDA/etc/profile.d/conda.sh + conda activate pyav + python -m pytest + + armv7: + name: "armv7 cross-build + qemu smoke" + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Set up host Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Set up host Python 3.14t + uses: actions/setup-python@v6 + with: + python-version: "3.14t" + - name: Install build tooling + run: | + for py in python3.11 python3.14t; do + $py -m pip install -U "cython>=3.1.0,<4" "setuptools>=77.0" wheel + done + # patchelf 0.18 (ubuntu-24.04's system version) corrupts ELF files + # with large p_align; pin <0.18 for auditwheel to use. + python3.11 -m pip install -U ziglang auditwheel 'patchelf<0.18' + - name: Cross-compile armv7l wheels with zig + env: + HOST_PY311: python3.11 + HOST_PY314T: python3.14t + TOOLS_PY: python3.11 + run: bash scripts/build-armv7l-cross.sh + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: arm + - name: Import-test wheels under emulation + run: | + docker run --rm --platform linux/arm/v7 -v "$PWD/dist:/dist:ro" \ + quay.io/pypa/manylinux_2_31_armv7l bash -ceu ' + for py in /opt/python/cp311-cp311 /opt/python/cp314-cp314t; do + echo "::group::$("$py/bin/python" --version)" + "$py/bin/pip" install --no-index --find-links /dist av + "$py/bin/python" -m av --version + echo "::endgroup::" + done' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ad4287a3c..6fb97330a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,202 +1,25 @@ name: tests - -on: [push, pull_request] - +on: + release: + types: [published] + workflow_dispatch: jobs: - - - style: - - name: "${{ matrix.config.suite }}" - runs-on: ubuntu-latest - - strategy: - matrix: - config: - - {suite: isort} - - {suite: flake8} - - env: - PYAV_PYTHON: python3 - PYAV_LIBRARY: ffmpeg-4.2 # doesn't matter - - steps: - - - uses: actions/checkout@v2 - name: Checkout - - - name: Python - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - - name: Environment - run: env | sort - - - name: Packages - run: | - . scripts/activate.sh - # A bit of a hack that we can get away with this. - python -m pip install ${{ matrix.config.suite }} - - - name: "${{ matrix.config.suite }}" - run: | - . scripts/activate.sh - ./scripts/test ${{ matrix.config.suite }} - - - nix: - - name: "py-${{ matrix.config.python }} lib-${{ matrix.config.ffmpeg }} ${{matrix.config.os}}" - - runs-on: ${{ matrix.config.os }} - - strategy: - matrix: - config: - - {os: ubuntu-latest, python: 3.7, ffmpeg: "4.2", extras: true} - - {os: ubuntu-latest, python: 3.7, ffmpeg: "4.1"} - - {os: ubuntu-latest, python: 3.7, ffmpeg: "4.0"} - - {os: ubuntu-latest, python: pypy3, ffmpeg: "4.2"} - #- {os: macos-latest, python: 3.7, ffmpeg: "4.2"} - - env: - PYAV_PYTHON: python${{ matrix.config.python }} - PYAV_LIBRARY: ffmpeg-${{ matrix.config.ffmpeg }} - - steps: - - - uses: actions/checkout@v2 - name: Checkout - - - name: Python ${{ matrix.config.python }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.config.python }} - - - name: OS Packages - run: | - case ${{ matrix.config.os }} in - ubuntu-latest) - sudo apt-get update - sudo apt-get install autoconf automake build-essential cmake \ - libtool mercurial pkg-config texinfo wget yasm zlib1g-dev - sudo apt-get install libass-dev libfreetype6-dev libjpeg-dev \ - libtheora-dev libvorbis-dev libx264-dev - if [[ "${{ matrix.config.extras }}" ]]; then - sudo apt-get install doxygen - fi - ;; - macos-latest) - brew update - brew install automake libtool nasm pkg-config shtool texi2html wget - brew install libass libjpeg libpng libvorbis libvpx opus theora x264 - ;; - esac - - - name: Pip and FFmpeg - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - scripts/build-deps - - - name: Build - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - scripts/build - - - name: Test - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - python -m av --version # Assert it can import. - scripts/test - - - name: Docs - if: matrix.config.extras - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - make -C docs html - - - name: Doctest - if: matrix.config.extras - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - scripts/test doctest - - - name: Examples - if: matrix.config.extras - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - scripts/test examples - - - name: Source Distribution - if: matrix.config.extras - run: | - . scripts/activate.sh ffmpeg-${{ matrix.config.ffmpeg }} - scripts/test sdist - - windows: - - name: "py-${{ matrix.config.python }} lib-${{ matrix.config.ffmpeg }} ${{matrix.config.os}}" - - runs-on: ${{ matrix.config.os }} - - strategy: - matrix: - config: - - {os: windows-latest, python: 3.7, ffmpeg: "4.2"} - - {os: windows-latest, python: 3.7, ffmpeg: "4.1"} - - {os: windows-latest, python: 3.7, ffmpeg: "4.0"} - - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up Conda - shell: bash - run: | - . $CONDA/etc/profile.d/conda.sh - conda config --set always_yes true - conda config --add channels conda-forge - conda create -q -n pyav \ - cython \ - ffmpeg=${{ matrix.config.ffmpeg }} \ - numpy \ - pillow \ - python=${{ matrix.config.python }} \ - setuptools - - - name: Build - shell: bash - run: | - . $CONDA/etc/profile.d/conda.sh - conda activate pyav - python setup.py build_ext --inplace --ffmpeg-dir=$CONDA_PREFIX/Library - - - name: Test - shell: bash - run: | - . $CONDA/etc/profile.d/conda.sh - conda activate pyav - python setup.py test - package-source: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v1 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 with: - python-version: 3.7 + python-version: "3.14" - name: Build source package run: | - pip install cython - python scripts/fetch-vendor /tmp/vendor - PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig make build - python setup.py sdist + pip install -U cython setuptools + python scripts/fetch-vendor.py --config-file scripts/ffmpeg-latest.json /tmp/vendor + PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig python setup.py sdist - name: Upload source package - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v6 with: - name: dist + name: dist-source path: dist/ package-wheel: @@ -204,52 +27,111 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: macos-14 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 + - os: ubuntu-24.04 + arch: x86_64 + - os: windows-latest + arch: AMD64 + - os: windows-11-arm + arch: ARM64 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v1 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 with: - python-version: 3.7 - - name: Install packages - if: matrix.os == 'macos-latest' + python-version: "3.14" + - name: Set Minimum MacOS Target + if: runner.os == 'macOS' run: | - brew update - brew install pkg-config + if [ "${{ matrix.arch }}" = "x86_64" ]; then + echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> $GITHUB_ENV + else + echo "MACOSX_DEPLOYMENT_TARGET=14.0" >> $GITHUB_ENV + fi - name: Build wheels env: - CIBW_BEFORE_BUILD: pip install cython && python scripts/fetch-vendor /tmp/vendor - CIBW_BEFORE_BUILD_WINDOWS: pip install cython && python scripts\fetch-vendor C:\cibw\vendor + CIBW_ARCHS: ${{ matrix.arch }} + CIBW_BEFORE_BUILD: python scripts/fetch-vendor.py --config-file scripts/ffmpeg-latest.json /tmp/vendor + CIBW_BEFORE_BUILD_MACOS: python scripts/fetch-vendor.py --config-file scripts/ffmpeg-latest.json /tmp/vendor + CIBW_BEFORE_BUILD_WINDOWS: python scripts\fetch-vendor.py --config-file scripts\ffmpeg-latest.json C:\cibw\vendor CIBW_ENVIRONMENT_LINUX: LD_LIBRARY_PATH=/tmp/vendor/lib:$LD_LIBRARY_PATH PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig CIBW_ENVIRONMENT_MACOS: PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig LDFLAGS=-headerpad_max_install_names CIBW_ENVIRONMENT_WINDOWS: INCLUDE=C:\\cibw\\vendor\\include LIB=C:\\cibw\\vendor\\lib PYAV_SKIP_TESTS=unicode_filename - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: python scripts/inject-dll {wheel} {dest_dir} C:\cibw\vendor\bin - CIBW_SKIP: cp27-* pp27-* pp36-win* - CIBW_TEST_COMMAND: mv {project}/av {project}/av.disabled && python -m unittest discover -t {project} -s tests && mv {project}/av.disabled {project}/av - # disable test suite on OS X, the SSL config seems broken - CIBW_TEST_COMMAND_MACOS: true - CIBW_TEST_REQUIRES: numpy + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: delvewheel repair --add-path C:\cibw\vendor\bin -w {dest_dir} {wheel} + CIBW_BUILD: "cp311* cp314t*" + CIBW_TEST_COMMAND: mv {project}/av {project}/av.disabled && python -m pytest {package}/tests && mv {project}/av.disabled {project}/av + CIBW_TEST_REQUIRES: pytest numpy run: | - pip install cibuildwheel + pip install cibuildwheel delvewheel cibuildwheel --output-dir dist shell: bash - name: Upload wheels - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v6 + with: + name: dist-${{ matrix.os }}-${{ matrix.arch }} + path: dist/ + + # armv7l (32-bit ARM) is cross-compiled with zig on a native x86_64 runner + # instead of emulated with QEMU. cibuildwheel cannot cross-compile, so this + # target uses its own driver (scripts/build-armv7l-cross.sh). + package-wheel-armv7l: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Set up host Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Set up host Python 3.14t + uses: actions/setup-python@v6 + with: + python-version: "3.14t" + - name: Install build tooling + run: | + for py in python3.11 python3.14t; do + $py -m pip install -U "cython>=3.1.0,<4" "setuptools>=77.0" wheel + done + # patchelf 0.18 (ubuntu-24.04's system version) corrupts ELF files + # with large p_align; pin <0.18 for auditwheel to use. + python3.11 -m pip install -U ziglang auditwheel 'patchelf<0.18' + - name: Cross-compile armv7l wheels with zig + env: + HOST_PY311: python3.11 + HOST_PY314T: python3.14t + TOOLS_PY: python3.11 + run: bash scripts/build-armv7l-cross.sh + - name: Upload wheels + uses: actions/upload-artifact@v6 with: - name: dist + name: dist-armv7l path: dist/ publish: runs-on: ubuntu-latest - needs: [package-source, package-wheel] + needs: [package-source, package-wheel, package-wheel-armv7l] steps: - - uses: actions/checkout@v2 - - uses: actions/download-artifact@v1 + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v7 with: - name: dist + merge-multiple: true path: dist/ + + - name: Publish to GitHub + if: github.event_name == 'release' + uses: softprops/action-gh-release@v1 + with: + files: dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish to PyPI - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/') - uses: pypa/gh-action-pypi-publish@master + if: github.event_name == 'release' && github.event.action == 'published' + uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore index bc6c09077..2e71c06bc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,11 +30,11 @@ /ipch /msvc-projects /src +/docs/_ffmpeg # Testing. *.spyderproject .idea -/.vagrant /sandbox /tests/assets /tests/samples diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..fd712fed0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Dev Workflow + +Run `source ./scripts/activate.sh` if not in virtualenv + +# 1. Make changes + +# 2. Build + +make + +# 3. Test + +make test # All tests +python -m pytest tests/some_file.py # Individual file +python -m pytest -k "substring" + +# 4. Lint before commiting + +make lint + +# Gotchas + +- Uses Cython so look out for UB diff --git a/AUTHORS.py b/AUTHORS.py index eb7ac721c..1d68e08b1 100644 --- a/AUTHORS.py +++ b/AUTHORS.py @@ -1,100 +1,182 @@ -import math -import subprocess +""" Generate the AUTHORS.rst file from git commit history. + +This module reads git commit logs and produces a formatted list of contributors +grouped by their contribution count, mapping email aliases and GitHub usernames. +""" +from dataclasses import dataclass +import math +import subprocess # noqa: S404 -print('''Contributors -============ -All contributors (by number of commits): -''') +def main() -> None: + """ Generate and print the AUTHORS.rst content. """ + contributors = get_git_contributors() + print_contributors(contributors) +# ------------------------------------------------------------------------------ -email_map = { +EMAIL_ALIASES: dict[str, str | None] = { # Maintainers. - 'git@mikeboers.com': 'github@mikeboers.com', - 'mboers@keypics.com': 'github@mikeboers.com', - 'mikeb@loftysky.com': 'github@mikeboers.com', - 'mikeb@markmedia.co': 'github@mikeboers.com', - 'westernx@mikeboers.com': 'github@mikeboers.com', - + "git@mikeboers.com": "github@mikeboers.com", + "mboers@keypics.com": "github@mikeboers.com", + "mikeb@loftysky.com": "github@mikeboers.com", + "mikeb@markmedia.co": "github@mikeboers.com", + "westernx@mikeboers.com": "github@mikeboers.com", # Junk. - 'mark@mark-VirtualBox.(none)': None, - + "mark@mark-VirtualBox.(none)": None, # Aliases. - 'a.davoudi@aut.ac.ir': 'davoudialireza@gmail.com', - 'tcaswell@bnl.gov': 'tcaswell@gmail.com', - 'xxr3376@gmail.com': 'xxr@megvii.com', - 'dallan@pha.jhu.edu': 'daniel.b.allan@gmail.com', - + "a.davoudi@aut.ac.ir": "davoudialireza@gmail.com", + "tcaswell@bnl.gov": "tcaswell@gmail.com", + "xxr3376@gmail.com": "xxr@megvii.com", + "dallan@pha.jhu.edu": "daniel.b.allan@gmail.com", + "61652821+laggykiller@users.noreply.github.com": "chaudominic2@gmail.com", } -name_map = { - 'caspervdw@gmail.com': 'Casper van der Wel', - 'daniel.b.allan@gmail.com': 'Dan Allan', - 'mgoacolou@cls.fr': 'Manuel Goacolou', - 'mindmark@gmail.com': 'Mark Reid', - 'moritzkassner@gmail.com': 'Moritz Kassner', - 'vidartf@gmail.com': 'Vidar Tonaas Fauske', - 'xxr@megvii.com': 'Xinran Xu', +CANONICAL_NAMES: dict[str, str] = { + "caspervdw@gmail.com": "Casper van der Wel", + "daniel.b.allan@gmail.com": "Dan Allan", + "mgoacolou@cls.fr": "Manuel Goacolou", + "mindmark@gmail.com": "Mark Reid", + "moritzkassner@gmail.com": "Moritz Kassner", + "vidartf@gmail.com": "Vidar Tonaas Fauske", + "xxr@megvii.com": "Xinran Xu", } -github_map = { - 'billy.shambrook@gmail.com': 'billyshambrook', - 'daniel.b.allan@gmail.com': 'danielballan', - 'davoudialireza@gmail.com': 'adavoudi', - 'github@mikeboers.com': 'mikeboers', - 'jeremy.laine@m4x.org': 'jlaine', - 'kalle.litterfeldt@gmail.com': 'litterfeldt', - 'mindmark@gmail.com': 'markreidvfx', - 'moritzkassner@gmail.com': 'mkassner', - 'rush@logic.cz': 'radek-senfeld', - 'self@brendanlong.com': 'brendanlong', - 'tcaswell@gmail.com': 'tacaswell', - 'ulrik.mikaelsson@magine.com': 'rawler', - 'vidartf@gmail.com': 'vidartf', - 'willpatera@gmail.com': 'willpatera', - 'xxr@megvii.com': 'xxr3376', +GITHUB_USERNAMES: dict[str, str] = { + "billy.shambrook@gmail.com": "billyshambrook", + "daniel.b.allan@gmail.com": "danielballan", + "davoudialireza@gmail.com": "adavoudi", + "github@mikeboers.com": "mikeboers", + "jeremy.laine@m4x.org": "jlaine", + "kalle.litterfeldt@gmail.com": "litterfeldt", + "mindmark@gmail.com": "markreidvfx", + "moritzkassner@gmail.com": "mkassner", + "rush@logic.cz": "radek-senfeld", + "self@brendanlong.com": "brendanlong", + "tcaswell@gmail.com": "tacaswell", + "ulrik.mikaelsson@magine.com": "rawler", + "vidartf@gmail.com": "vidartf", + "willpatera@gmail.com": "willpatera", + "xxr@megvii.com": "xxr3376", + "chaudominic2@gmail.com": "laggykiller", + "wyattblue@auto-editor.com": "WyattBlue", + "Curtis@GreenKey.net": "dotysan", } -email_count = {} -for line in subprocess.check_output(['git', 'log', '--format=%aN,%aE']).decode().splitlines(): - name, email = line.strip().rsplit(',', 1) +@dataclass +class Contributor: + """ Represents a contributor with their email, names, and GitHub username. """ + + email: str + names: set[str] + github: str | None = None + commit_count: int = 0 + + @property + def display_name(self) -> str: + """ Return the formatted display name for the contributor. + + Returns: + Comma-separated sorted list of contributor names. + """ + + return ", ".join(sorted(self.names)) + + def format_line(self, bullet: str) -> str: + """ Format the contributor line for RST output. + + Args: + bullet: The bullet character to use (- or *). + + Returns: + Formatted RST line with contributor info. + """ + + if self.github: + return ( + f"{bullet} {self.display_name} <{self.email}>; " + f"`@{self.github} `_" + ) + return f"{bullet} {self.display_name} <{self.email}>" + + +def get_git_contributors() -> dict[str, Contributor]: + """ Parse git log and return contributors grouped by canonical email. + + Returns: + Dictionary mapping canonical emails to Contributor objects. + """ + + contributors: dict[str, Contributor] = {} + git_log = subprocess.check_output( + ["git", "log", "--format=%aN,%aE"], # noqa: S607 + text=True, + ).splitlines() + + for line in git_log: + name, email = line.strip().rsplit(",", 1) + canonical_email = EMAIL_ALIASES.get(email, email) + + if not canonical_email: + continue + + if canonical_email not in contributors: + contributors[canonical_email] = Contributor( + email=canonical_email, + names=set(), + github=GITHUB_USERNAMES.get(canonical_email), + ) + + contributor = contributors[canonical_email] + contributor.names.add(name) + contributor.commit_count += 1 + + for email, canonical_name in CANONICAL_NAMES.items(): + if email in contributors: + contributors[email].names = {canonical_name} + + return contributors + + +def print_contributors(contributors: dict[str, Contributor]) -> None: + """Print contributors grouped by logarithmic order of commits. + + Args: + contributors: Dictionary of contributors to print. + """ - email = email_map.get(email, email) - if not email: - continue + print("""\ + Contributors + ============ - names = name_map.setdefault(email, set()) - if isinstance(names, set): - names.add(name) + All contributors (by number of commits): + """.replace(" ", "")) - email_count[email] = email_count.get(email, 0) + 1 + sorted_contributors = sorted( + contributors.values(), + key=lambda c: (-c.commit_count, c.email), + ) + last_order: int | None = None + block_index = 0 -last = None -block_i = 0 -for email, count in sorted(email_count.items(), key=lambda x: (-x[1], x[0])): + for contributor in sorted_contributors: + # This is the natural log, because of course it should be. ;) + order = int(math.log(contributor.commit_count)) - # This is the natural log, because of course it should be. ;) - order = int(math.log(count)) - if last and last != order: - block_i += 1 - print() - last = order + if last_order and last_order != order: + block_index += 1 + print() + last_order = order - names = name_map[email] - if isinstance(names, set): - name = ', '.join(sorted(names)) - else: - name = names + # The '-' vs '*' is so that Sphinx treats them as different lists, and + # introduces a gap between them. + bullet = "-*"[block_index % 2] + print(contributor.format_line(bullet)) - github = github_map.get(email) - # The '-' vs '*' is so that Sphinx treats them as different lists, and - # introduces a gap bettween them. - if github: - print('%s %s <%s>; `@%s `_' % ('-*'[block_i % 2], name, email, github, github)) - else: - print('%s %s <%s>' % ('-*'[block_i % 2], name, email, )) +if __name__ == "__main__": + main() diff --git a/AUTHORS.rst b/AUTHORS.rst index 94601edd3..5238156e2 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -4,43 +4,126 @@ Contributors All contributors (by number of commits): - Mike Boers ; `@mikeboers `_ +- WyattBlue ; `@WyattBlue `_ * Jeremy Lainé ; `@jlaine `_ -* Mark Reid ; `@markreidvfx `_ + +- Mark Reid ; `@markreidvfx `_ + +* Lukas Geiger - Vidar Tonaas Fauske ; `@vidartf `_ +- laggykiller ; `@laggykiller `_ - Billy Shambrook ; `@billyshambrook `_ - Casper van der Wel +- Philip de Nier - Tadas Dailyda +- Dave Johansen +- Mark Harfouche +- JoeUgly <41972063+JoeUgly@users.noreply.github.com> +- Justin Wong <46082645+uvjustin@users.noreply.github.com> +- Santtu Keskinen +* Alba Mendez +* Curtis Doty ; `@dotysan `_ * Xinran Xu ; `@xxr3376 `_ +* z-khan +* Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Dan Allan ; `@danielballan `_ +* Moonsik Park +* velsinki <40809145+velsinki@users.noreply.github.com> +* Christoph Rackwitz +* David Plowman * Alireza Davoudi ; `@adavoudi `_ +* Jonathan Drolet +* Matthew Lai +* Kim Minjong * Moritz Kassner ; `@mkassner `_ * Thomas A Caswell ; `@tacaswell `_ * Ulrik Mikaelsson ; `@rawler `_ * Wel C. van der * Will Patera ; `@willpatera `_ +- zzjjbb <31069326+zzjjbb@users.noreply.github.com> +- Joe Schiff <41972063+JoeSchiff@users.noreply.github.com> +- Nils DEYBACH <68770774+ndeybach@users.noreply.github.com> +- Dexer <73297572+DexerBR@users.noreply.github.com> +- DE-AI <81620697+DE-AI@users.noreply.github.com> - rutsh -- Christoph Rackwitz +- Felix Vollmer +- Benedikt Lorch, benedikt-grl +- Santiago Castro +- Christian Clauss +- Ihor Liubymov - Johannes Erdfelt - Karl Litterfeldt ; `@litterfeldt `_ +- Leon White - Martin Larralde +- Simon-Martin Schröder +- Matteo Destro +- Mattias Wadman +- mephi42 - Miles Kaufmann +- Nathan Goldbaum +- Pablo Prietz +- Andrew Wason - Radek Senfeld ; `@radek-senfeld `_ +- robinechuca +- Nick <24689722+ntjohnson1@users.noreply.github.com> +- Benjamin Chrétien <2742231+bchretien@users.noreply.github.com> +- 吴小白 <296015668@qq.com> +- davidplowman <38045873+davidplowman@users.noreply.github.com> +- Hanz <40712686+HanzCEO@users.noreply.github.com> +- Clay Castronovo <42858023+clayy24@users.noreply.github.com> +- Kesh Ikuma <79113787+tikuma-lsuhsc@users.noreply.github.com> +- Artturin - Ian Lee +- Ryan Huang - Arthur Barros +- bdavid-evertz +- Carlos Ruiz +- Carlos Ruiz +- Maxime Desroches +- egao1980 +- Eric Kalosa-Kenyon +- elxy - Gemfield -- mephi42 +- henri-gasc +- Jonathan Martin +- HotariTobu +- Joshua +- Johan Jeppsson Karlin +- Kazuki Oikawa +- Kian-Meng Ang +- Philipp Klaus +- Marcell Pardavi +- Matteo Destro +- Max Ehrlich - Manuel Goacolou +- Julian Schweizer +- Nikhil Idiculla - Ömer Sezgin Uğurlu - Orivej Desh +- Philipp Krähenbühl +- Mattia Procopio +- Max Ehrlich +- ramoncaldeira +- Roland van Laar +- Santiago Castro +- Kengo Sawatsu +- FirefoxMetzger +- hyenal - Brendan Long ; `@brendanlong `_ +- Семён Марьясин +- Stephen.Y - Tom Flanagan - Tim O'Shea - Tim Ahpee - Jonas Tingeborn +- Pino Toscano +- Ulrik Mikaelsson - Vasiliy Kotov - Koichi Akabe - David Joy +- Sviatoslav Sydorenko (Святослав Сидоренко) +- Jiabei Zhu diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2fa11f678..ce37ff4f9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,527 +1,205 @@ Changelog ========= -We are operating with `semantic versioning `_. +We are operating with `semantic versioning `_. .. - Please try to update this file in the commits that make the changes. + Update this file in your commit that makes a change (besides maintenance). - To make merging/rebasing easier, we don't manually break lines in here - when they are too long, so any particular change is just one line. + To make merging/rebasing easier, don't manually break lines in here when they are too long. + To make tracking easier, please add either ``closes #123`` or ``fixes #123`` to the first line of the commit message, when closing/fixing a GitHub issue. + Changelog entries will be limited to the latest major version and (next) to prevent exponential growth in file storage. + Use the Oxford comma. - To make tracking easier, please add either ``closes #123`` or ``fixes #123`` - to the first line of the commit message. There are more syntaxes at: - . + Entries look like this: - Note that they these tags will not actually close the issue/PR until they - are merged into the "default" branch, currently "develop"). + v21.67.42 + --------- + Major: -v8.0.4.dev0 ------- + - Breaking changes (MAJOR) go here, including for binary wheels. + Features: -v8.0.3 ------- + - Features (MINOR) changes go here. -Minor: + Fixes: -- Update FFmpeg to 4.3.1 for the binary wheels. + - Bug fixes (PATCH) go here. + - $CHANGE by :gh-user:`mikeboers` in (:pr:`1`). -v8.0.2 ------- +v18.1.0 (Unreleased) +-------------------- -Minor: +Features: -- Enable GnuTLS support in the FFmpeg build used for binary wheels (:issue:`675`). -- Make binary wheels compatible with Mac OS X 10.9+ (:issue:`662`). -- Drop Python 2.x buffer protocol code. -- Remove references to previous repository location. - -v8.0.1 ------- - -Minor: - -- Enable additional FFmpeg features in the binary wheels. -- Use os.fsencode for both input and output file names (:issue:`600`). - -v8.0.0 ------- - -Major: - -- Drop support for Python 2 and Python 3.4. -- Provide binary wheels for Linux, Mac and Windows. - -Minor: - -- Remove shims for obsolete FFmpeg versions (:issue:`588`). -- Add yuvj420p format for :meth:`VideoFrame.from_ndarray` and :meth:`VideoFrame.to_ndarray` (:issue:`583`). -- Add support for palette formats in :meth:`VideoFrame.from_ndarray` and :meth:`VideoFrame.to_ndarray` (:issue:`601`). -- Fix Python 3.8 deprecation warning related to abstract base classes (:issue:`616`). -- Remove ICC profiles from logos (:issue:`622`). +- Infer the specific stream and codec context types for all FFmpeg encoder names in type-checked code by :gh-user:`WyattBlue`. +- Add ``CodecContext.supported_options`` to discover generic and codec-specific options by :gh-user:`WyattBlue` (:issue:`2032`). +- Add ``Packet.rescale_ts()`` to rescale packet PTS, DTS, and duration to a new ``AVRational`` time base by :gh-user:`WyattBlue`. +- Support reusing the thread's current CUDA context via a ``current_ctx`` flag on ``CudaContext`` and ``VideoFrame.from_dlpack``, for interop with libraries like PyTorch that initialize CUDA first by :gh-user:`Yozer` (:pr:`2339`). +- ``VideoFrame.from_dlpack`` no longer requires restating ``primary_ctx``/``current_ctx`` when passing an explicit ``cuda_context``; the flags are only validated when explicitly given by :gh-user:`WyattBlue`. +- Support passing an explicit CUDA stream to FFmpeg CUDA operations, including NVENC input and output, via a ``cuda_stream`` parameter on ``CudaContext``; currently limited to logical CUDA device 0 by :gh-user:`Yozer` (:pr:`2360`). Fixes: -- Avoid infinite timeout in :func:`av.open` (:issue:`589`). +- Prevent crashes and corrupted output when structural codec properties are changed after an output stream has been opened by :gh-user:`WyattBlue`, reported by :gh-user:`oakaigh` (:issue:`2232`). +- Fix a crash when using a stream that has no ``CodecContext`` (a demuxed stream with no available decoder, such as one from a truncated file, or a stream created by ``add_mux_stream``); decoding now raises ``DecoderNotFoundError``, encoding now raises ``EncoderNotFoundError``, and ``BitStreamFilterContext`` accepts such a stream as ``out_stream`` by :gh-user:`WyattBlue`, reported by :gh-user:`justinrmiller` (:issue:`2344`). -v7.0.1 ------- +v18.0.0 +------- -Fixes: - -- Removed deprecated ``AV_FRAME_DATA_QP_TABLE_*`` enums. (:issue:`607`) - - -v7.0.0 ------- +Breaking: -Major: - -- Drop support for FFmpeg < 4.0. (:issue:`559`) -- Introduce per-error exceptions, and mirror the builtin exception hierarchy. It is recommended to examine your error handling code, as common FFmpeg errors will result in `ValueError` baseclasses now. (:issue:`563`) -- Data stream's `encode` and `decode` return empty lists instead of none allowing common API use patterns with data streams. -- Remove ``whence`` parameter from :meth:`InputContainer.seek` as non-time seeking doesn't seem to actually be supported by any FFmpeg formats. +- Remove Python 3.10 -Minor: +Features: -- Users can disable the logging system to avoid lockups in sub-interpreters. (:issue:`545`) -- Filters support audio in general, and a new :meth:`.Graph.add_abuffer`. (:issue:`562`) -- :func:`av.open` supports `timeout` parameters. (:issue:`480` and :issue:`316`) -- Expose :attr:`Stream.base_rate` and :attr:`Stream.guessed_rate`. (:issue:`564`) -- :meth:`.VideoFrame.reformat` can specify interpolation. -- Expose many sets of flags. +- Support HW encoding via a ``hwaccel`` parameter on ``OutputContainer.add_stream`` (e.g. ``h264_vaapi``, ``h264_nvenc``, ``h264_videotoolbox``); software frames passed to ``encode`` are uploaded to the device automatically by :gh-user:`WyattBlue` (:issue:`2156`). +- Use FFmpeg 8.1.2 in the binary wheels by :gh-user:`WyattBlue`. +- Expose ``sw_format`` on ``VideoCodecContext``, and allow configuring the software format of hardware encoders by :gh-user:`WyattBlue`. +- Add ``options`` parameter to ``AudioResampler`` for passing ``libswresample`` options (e.g. ``resampler``, ``filter_size``, ``cutoff``) by :gh-user:`WyattBlue` (:issue:`2262`). +- Support ``yuv420p10le`` in ``VideoFrame.to_ndarray`` and ``VideoFrame.from_ndarray`` by :gh-user:`WyattBlue` (:issue:`1981`). +- Add ``at`` parameter to ``Graph.push`` and ``Graph.vpush`` to push a frame to a single buffer source by index, for multi-input filters like ``overlay`` by :gh-user:`WyattBlue`. +- ``find_best_pix_fmt_of_list`` now returns the loss as a ``PixFmtLoss`` ``enum.IntFlag`` instead of a plain ``int`` by :gh-user:`WyattBlue` (:issue:`2300`). +- Add ``Colorspace.BT2020`` by :gh-user:`mark-oshea`. +- ``BitStreamFilterContext`` now accepts a ``Codec`` or a codec-name ``str`` as ``in_stream`` to pin the input codec without a full ``Stream`` by :gh-user:`WyattBlue`. Fixes: -- Fix typing in :meth:`.CodecContext.parse` and make it more robust. -- Fix wrong attribute in ByteSource. (:issue:`340`) -- Remove exception that would break audio remuxing. (:issue:`537`) -- Log messages include last FFmpeg error log in more helpful way. -- Use AVCodecParameters so FFmpeg doesn't complain. (:issue:`222`) - - -v6.2.0 ------- - -Major: - -- Allow :meth:`av.open` to be used as a context manager. -- Fix compatibility with PyPy, the full test suite now passes. (:issue:`130`) - -Minor: - -- Add :meth:`.InputContainer.close` method. (:issue:`317`, :issue:`456`) -- Ensure audio output gets flushes when using a FIFO. (:issue:`511`) -- Make Python I/O buffer size configurable. (:issue:`512`) -- Make :class:`.AudioFrame` and :class:`VideoFrame` more garbage-collector friendly by breaking a reference cycle. (:issue:`517`) - -Build: - -- Do not install the `scratchpad` package. - - -v6.1.2 ------- - -Micro: - -- Fix a numpy deprecation warning in :meth:`.AudioFrame.to_ndarray`. - - -v6.1.1 ------- - -Micro: - -- Fix alignment in :meth:`.VideoFrame.from_ndarray`. (:issue:`478`) -- Fix error message in :meth:`.Buffer.update`. - -Build: - -- Fix more compiler warnings. - - -v6.1.0 ------- - -Minor: - -- ``av.datasets`` for sample data that is pulled from either FFmpeg's FATE suite, or our documentation server. -- :meth:`.InputContainer.seek` gets a ``stream`` argument to specify the ``time_base`` the requested ``offset`` is in. - -Micro: - -- Avoid infinite look in ``Stream.__getattr__``. (:issue:`450`) -- Correctly handle Python I/O with no ``seek`` method. -- Remove ``Datastream.seek`` override (:issue:`299`) - -Build: - -- Assert building against compatible FFmpeg. (:issue:`401`) -- Lock down Cython lanaguage level to avoid build warnings. (:issue:`443`) - -Other: - -- Incremental improvements to docs and tests. -- Examples directory will now always be runnable as-is, and embeded in the docs (in a copy-pastable form). - - -v6.0.0 ------- - -Major: - -- Drop support for FFmpeg < 3.2. -- Remove ``VideoFrame.to_qimage`` method, as it is too tied to PyQt4. (:issue:`424`) - -Minor: - -- Add support for all known sample formats in :meth:`.AudioFrame.to_ndarray` and add :meth:`.AudioFrame.to_ndarray`. (:issue:`422`) -- Add support for more image formats in :meth:`.VideoFrame.to_ndarray` and :meth:`.VideoFrame.from_ndarray`. (:issue:`415`) - -Micro: - -- Fix a memory leak in :meth:`.OutputContainer.mux_one`. (:issue:`431`) -- Ensure :meth:`.OutputContainer.close` is called at destruction. (:issue:`427`) -- Fix a memory leak in :class:`.OutputContainer` initialisation. (:issue:`427`) -- Make all video frames created by PyAV use 8-byte alignment. (:issue:`425`) -- Behave properly in :meth:`.VideoFrame.to_image` and :meth:`.VideoFrame.from_image` when ``width != line_width``. (:issue:`425`) -- Fix manipulations on video frames whose width does not match the line stride. (:issue:`423`) -- Fix several :attr:`.Plane.line_size` misunderstandings. (:issue:`421`) -- Consistently decode dictionary contents. (:issue:`414`) -- Always use send/recv en/decoding mechanism. This removes the ``count`` parameter, which was not used in the send/recv pipeline. (:issue:`413`) -- Remove various deprecated iterators. (:issue:`412`) -- Fix a memory leak when using Python I/O. (:issue:`317`) -- Make :meth:`.OutputContainer.mux_one` call `av_interleaved_write_frame` with the GIL released. - -Build: - -- Remove the "reflection" mechanism, and rely on FFmpeg version we build against to decide which methods to call. (:issue:`416`) -- Fix many more ``const`` warnings. - - -v0.x.y ------- - -.. note:: - - Below here we used ``v0.x.y``. - - We incremented ``x`` to signal a major change (i.e. backwards - incompatibilities) and incremented ``y`` as a minor change (i.e. backwards - compatible features). - - Once we wanted more subtlety and felt we had matured enough, we jumped - past the implications of ``v1.0.0`` straight to ``v6.0.0`` - (as if we had not been stuck in ``v0.x.y`` all along). - - -v0.5.3 ------- - -Minor: - -- Expose :attr:`.VideoFrame.pict_type` as :class:`.PictureType` enum. - (:pr:`402`) -- Expose :attr:`.Codec.video_rates` and :attr:`.Codec.audio_rates`. - (:pr:`381`) - -Patch: +- Fix ``VideoFrame.reformat`` (and ``to_ndarray``/``to_rgb``/``to_image``) raising ``OSError`` ``Operation not supported`` on frames tagged with reserved or otherwise unsupported ``color_primaries``/``color_trc`` values (e.g. VP9 and NVDEC output); a transfer/primaries conversion is now only performed when explicitly requested by :gh-user:`WyattBlue` (:issue:`2208`). +- Fix ``add_mux_stream`` producing unwritable Matroska files by extracting codec extradata from the bitstream before the header is written by :gh-user:`WyattBlue` (:issue:`2198`). +- Encode GPU frames (e.g. CUDA frames from DLPack) directly with ``pix_fmt="cuda"`` by adopting the frame's ``hw_frames_ctx`` before opening the encoder by :gh-user:`WyattBlue` (:issue:`2199`). +- Fix a crash when reading ``VideoCodecContext.pix_fmt`` before it is set; it now returns ``None`` by :gh-user:`CarlosRDomin`. +- Preserve frame attributes (``pts``, ``time_base``, colorspace, etc.) when downloading hardware frames to system memory by :gh-user:`CarlosRDomin`. -- Fix :attr:`.Packet.time_base` handling during flush. - (:pr:`398`) -- :meth:`.VideoFrame.reformat` can throw exceptions when requested colorspace - transforms aren't possible. -- Wrapping the stream object used to overwrite the ``pix_fmt`` attribute. - (:pr:`390`) +v17.1.0 +------- -Runtime: +Breaking: -- Deprecate ``VideoFrame.ptr`` in favour of :attr:`VideoFrame.buffer_ptr`. -- Deprecate ``Plane.update_buffer()`` and ``Packet.update_buffer`` in favour of - :meth:`.Plane.update`. - (:pr:`407`) -- Deprecate ``Plane.update_from_string()`` in favour of :meth:`.Plane.update`. - (:pr:`407`) -- Deprecate ``AudioFrame.to_nd_array()`` and ``VideoFrame.to_nd_array()`` in - favour of :meth:`.AudioFrame.to_ndarray` and :meth:`.VideoFrame.to_ndarray`. - (:pr:`404`) +- Remove the undertested ``av.option`` and ``av.descriptor`` APIs, along with the related ``Codec`` and ``Filter`` descriptor accessors. -Build: +Features: -- CI covers more cases, including macOS. - (:pr:`373` and :pr:`399`) -- Fix many compilation warnings. - (:issue:`379`, :pr:`380`, :pr:`387`, and :pr:`388`) +- Use FFmpeg 8.1.1 in the binary wheels by :gh-user:`WyattBlue`. +- Build Linux ARMv7 binary wheels by :gh-user:`WyattBlue`. +- Expose ``AVCodecContext.global_quality`` by :gh-user:`WyattBlue` in (:pr:`2246`). +- Expose ``Stream.discard`` so demuxing and seeking can skip unwanted streams by :gh-user:`WyattBlue` (:issue:`2272`). +- Add ``Stream.set_display_matrix()`` and ``Stream.set_display_rotation()`` to write the container display (rotation) matrix on output streams by :gh-user:`hmaarrfk` in (:pr:`2287`). +- Add ``Container.video_codec_id`` to force a specific video codec on a container by :gh-user:`WyattBlue` (:issue:`2243`). -Docs: - -- Docstrings for many commonly used attributes. - (:pr:`372` and :pr:`409`) - - -v0.5.2 ------- - -Build: - -- Fixed Windows build, which broke in v0.5.1. -- Compiler checks are not cached by default. This behaviour is retained if you - ``source scripts/activate.sh`` to develop PyAV. - (:issue:`256`) -- Changed to ``PYAV_SETUP_REFLECT_DEBUG=1`` from ``PYAV_DEBUG_BUILD=1``. - - -v0.5.1 ------- - -Build: - -- Set ``PYAV_DEBUG_BUILD=1`` to force a verbose reflection (mainly for being - installed via ``pip``, which is why this is worth a release). - - -v0.5.0 ------- - -Major: - -- Dropped support for Libav in general. - (:issue:`110`) -- No longer uses libavresample. - -Minor: - -- ``av.open`` has ``container_options`` and ``stream_options``. -- ``Frame`` includes ``pts`` in ``repr``. - -Patch: - -- EnumItem's hash calculation no longer overflows. - (:issue:`339`, :issue:`341` and :issue:`342`.) -- Frame.time_base was not being set in most cases during decoding. - (:issue:`364`) -- CodecContext.options no longer needs to be manually initialized. -- CodexContext.thread_type accepts its enums. - - -v0.4.1 ------- - -Minor: - -- Add `Frame.interlaced_frame` to indicate if the frame is interlaced. - (:issue:`327` by :gh-user:`MPGek`) -- Add FLTP support to ``Frame.to_nd_array()``. - (:issue:`288` by :gh-user:`rawler`) -- Expose ``CodecContext.extradata`` for codecs that have extra data, e.g. - Huffman tables. - (:issue:`287` by :gh-user:`adavoudi`) - -Patch: +Fixes: -- Packets retain their refcount after muxing. - (:issue:`334`) -- `Codec` construction is more robust to find more codecs. - (:issue:`332` by :gh-user:`adavoudi`) -- Refined frame corruption detection. - (:issue:`291` by :gh-user:`Litterfeldt`) -- Unicode filenames are okay. - (:issue:`82`) +- Add ``cython.final`` to leaf classes, ensuring that they are not subclassed by :gh-user:`WyattBlue`. +- Warn that ``CodecContext.decode()`` is not memory safe in some cases. +- Fix memory leaks in ``FFmpegError``, ``AudioLayout`` channel layouts, and ``Frame.opaque``, and break a reference cycle between ``FilterLink`` and ``Graph`` by :gh-user:`lgeiger`. +- Reduce excessive logging lock contention by :gh-user:`WyattBlue` (:issue:`2276`). +- Fix a crash when accessing ``Stream`` from multiple threads under FFmpeg 8.1 by :gh-user:`WyattBlue` (:issue:`2247`). +- Fix a crash during ``InputContainer`` initialization by :gh-user:`WyattBlue` (:issue:`2010`). +- Fix ``enumerate_input_devices`` and ``enumerate_output_devices`` raising ``AttributeError`` by :gh-user:`WyattBlue` and :gh-user:`kazuki` (:issue:`2264`). +- Map HTTP 429 to ``HTTPTooManyRequestsError`` instead of ``UndefinedError`` by :gh-user:`WyattBlue` (:issue:`2267`). +- Fix crash in ``VideoFrame.to_ndarray()`` and ``to_image()`` on bottom-up frames with a negative ``line_size`` by :gh-user:`WyattBlue` (:issue:`2213`). +- Make ``Disposition`` an ``IntFlag`` so ``Stream.disposition`` can be assigned without raising ``TypeError`` by :gh-user:`HotariTobu`. +- Assign parser-inferred ``pts``, ``dts``, and ``duration`` to packets from ``CodecContext.parse()`` by :gh-user:`WyattBlue` (:issue:`1919`). +- Copy ``time_base`` in ``add_stream_from_template()`` by :gh-user:`daveisfera` in (:pr:`2249`). +- Fix the remux examples dropping keyframes that demux with no DTS, which produced audio-only output by :gh-user:`WyattBlue` (:issue:`1917`). +- Fix subtitle UTF-8 handling by :gh-user:`jbree` in (:pr:`2271`). +- Fix several incorrect ``malloc`` size calculations by :gh-user:`WyattBlue`. + +v17.0.1 +------- +Fixes: -v0.4.0 ------- +- A cdivision decorator for faster division. +- Adjust tests so they work with 8.1 by :gh-user:`strophy`. +- Make VideoFormat.components lazy by :gh-user:`WyattBlue`. +- Break reference cycle ``StreamContainer.get()`` by :gh-user:`lgeiger`. +- Expose threads in filter graph by :gh-user:`lgeiger`. +- Fix crash with container closing with GC by :gh-user:`WyattBlue`. +- Fix "Writing packets to data stream broken in av>=17" regression :issue:`2223` by :gh-user:`WyattBlue`. +- Remove ``_send_packet_and_recv`` to simplify ``decode()`` by :gh-user:`lgeiger`. +- Reuse a ``AVPacket`` read buffer when demuxing by :gh-user:`lgeiger` and :gh-user:`WyattBlue`. +- Use ``AVCodecContext.frame_num`` instead of counting ourselves by :gh-user:`WyattBlue`. +- Use ``av_channel_layout_compare()`` for ``AudioLayout.__eq__()`` by :gh-user:`WyattBlue`. + +v17.0.0 +------- Major: -- ``CodecContext`` has taken over encoding/decoding, and can work in isolation - of streams/containers. -- ``Stream.encode`` returns a list of packets, instead of a single packet. -- ``AudioFifo`` and ``AudioResampler`` will raise ``ValueError`` if input frames - inconsistant ``pts``. -- ``time_base`` use has been revisited across the codebase, and may not be converted - bettween ``Stream.time_base`` and ``CodecContext.time_base`` at the same times - in the transcoding pipeline. -- ``CodecContext.rate`` has been removed, but proxied to ``VideoCodecContext.framerate`` - and ``AudioCodecContext.sample_rate``. The definition is effectively inverted from - the old one (i.e. for 24fps it used to be ``1/24`` and is now ``24/1``). -- Fractions (e.g. ``time_base``, ``rate``) will be ``None`` if they are invalid. -- ``InputContainer.seek`` and ``Stream.seek`` will raise TypeError if given - a float, when previously they converted it from seconds. - -Minor: - -- Added ``Packet.is_keyframe`` and ``Packet.is_corrupt``. - (:issue:`226`) -- Many more ``time_base``, ``pts`` and other attributes are writeable. -- ``Option`` exposes much more of the API (but not get/set). - (:issue:`243`) -- Expose metadata encoding controls. - (:issue:`250`) -- Expose ``CodecContext.skip_frame``. - (:issue:`259`) - -Patch: - -- Build doesn't fail if you don't have git installed. - (:issue:`184`) -- Developer environment works better with Python3. - (:issue:`248`) -- Fix Container deallocation resulting in segfaults. - (:issue:`253`) - - -v0.3.3 ------- - -Patch: - -- Fix segfault due to buffer overflow in handling of stream options. - (:issue:`163` and :issue:`169`) -- Fix segfault due to seek not properly checking if codecs were open before - using avcodec_flush_buffers. - (:issue:`201`) +- Limited API binary wheels are now built. +- 3.13t (free-threading) will be dropped because of storage limitations. +- When an FFmpeg C function indicates an error, raise av.ArgumentError instead of ValueError/av.ValueError. This helps disambiguate why an exception is being thrown. +- Save space by removing libaom (av1 encoder/decoder); dav1d, stvav1, and hardware, are available alternatives. +Features: -v0.3.2 ------- +- Add ``OutputContainer.add_mux_stream()`` for creating codec-context-free streams, enabling muxing of pre-encoded packets without an encoder, addressing :issue:`1970` by :gh-user:`WyattBlue`. +- Use zero-copy for Packet init from buffer data by :gh-user:`WyattBlue` in (:pr:`2199`). +- Expose AVIndexEntry by :gh-user:`Queuecumber` in (:pr:`2136`). +- Preserving hardware memory during cuvid decoding, exporting/importing via dlpack by :gh-user:`WyattBlue` in (:pr:`2155`). +- Add enumerate_input_devices and enumerate_output_devices API by :gh-user:`WyattBlue` in (:pr:`2174`). +- Add ``ColorTrc`` and ``ColorPrimaries`` enums; add ``color_trc`` and ``color_primaries`` properties to ``VideoFrame``; add ``dst_color_trc`` and ``dst_color_primaries`` parameters to ``VideoFrame.reformat()``, addressing :issue:`1968` by :gh-user:`WyattBlue` in (:pr:`2175`). +- Add multithreaded reformatting and reduce Python overhead by replacing ``sws_scale`` with ``sws_scale_frame``, skipping unnecessary reformats, and minimizing Python interactions by :gh-user:`lgeiger`. +- Prevent data copy in ``VideoFrame.to_ndarray()`` for padded frames by :gh-user:`lgeiger` in (:pr:`2190`). -Minor: - -- Expose basics of avfilter via ``Filter``. -- Add ``Packet.time_base``. -- Add ``AudioFrame.to_nd_array`` to match same on ``VideoFrame``. -- Update Windows build process. - -Patch: - -- Further improvements to the logging system. - (:issue:`128`) - - -v0.3.1 ------- - -Minor: - -- ``av.logging.set_log_after_shutdown`` renamed to ``set_print_after_shutdown`` -- Repeating log messages will be skipped, much like ffmpeg's does by default - -Patch: - -- Fix memory leak in logging system when under heavy logging loads while - threading. - (:issue:`128` with help from :gh-user:`mkassner` and :gh-user:`ksze`) - - -v0.3.0 ------- - -Major: - -- Python IO can write -- Improve build system to use Python's C compiler for function detection; - build system is much more robust -- MSVC support. - (:issue:`115` by :gh-user:`vidartf`) -- Continuous integration on Windows via AppVeyor. (by :gh-user:`vidartf`) - -Minor: - -- Add ``Packet.decode_one()`` to skip packet flushing for codecs that would - otherwise error -- ``StreamContainer`` for easier selection of streams -- Add buffer protocol support to Packet +Fixes: -Patch: +- Fix :issue:`2149` by :gh-user:`WyattBlue` in (:pr:`2155`). +- Fix packet typing based on stream and specify InputContainer.demux based on incoming stream by :gh-user:`ntjohnson1` in (:pr:`2134`). +- Fix memory growth when remuxing with ``add_stream_from_template`` by skipping ``avcodec_open2`` for template-initialized codec contexts, addressing :issue:`2135` by :gh-user:`WyattBlue`. +- Explicitly disable OpenSSL in source builds (``scripts/build-deps``) to prevent accidental OpenSSL linkage that breaks FIPS-enabled systems, addressing :issue:`1972`. +- Add missing ``SWS_SPLINE`` interpolation by :gh-user:`lgeiger` in (:pr:`2188`). -- Fix bug when using Python IO on files larger than 2GB. - (:issue:`109` by :gh-user:`xxr3376`) -- Fix usage of changed Pillow API +v16.1.0 +------- -Known Issues: +Features: -- VideoFrame is suspected to leak memory in narrow cases on Linux. - (:issue:`128`) +- Add support for Intel QSV codecs by :gh-user:`ladaapp`. +- Add AMD AMF hardware decoding by :gh-user:`ladaapp2`. +- Add subtitle encoding support by :gh-user:`skeskinen` in (:pr:`2050`). +- Add read/write access to PacketSideData by :gh-user:`skeskinen` in (:pr:`2051`). +- Add yuv422p support for video frame to_ndarray and from_ndarray by :gh-user:`wader` in (:pr:`2054`). +- Add binding for ``avcodec_find_best_pix_fmt_of_list()`` by :gh-user:`ndeybach` in (:pr:`2058`). +Fixes: -v0.2.4 ------- +- Fix #2036, #2053, #2057 by :gh-user:`WyattBlue`. -- fix library search path for current Libav/Ubuntu 14.04. - (:issue:`97`) -- explicitly include all sources to combat 0.2.3 release problem. - (:issue:`100`) +v16.0.1 +------- +Fixes: -v0.2.3 ------- +- Add new hwaccel enums by :gh-user:`WyattBlue` in (:pr:`2030`). -.. warning:: There was an issue with the PyPI distribution in which it required - Cython to be installed. +v16.0.0 +------- Major: -- Python IO. -- Agressively releases GIL -- Add experimental Windows build. - (:issue:`84`) - -Minor: - -- Several new Stream/Packet/Frame attributes - -Patch: - -- Fix segfault in audio handling. - (:issue:`86` and :issue:`93`) -- Fix use of PIL/Pillow API. - (:issue:`85`) -- Fix bad assumptions about plane counts. - (:issue:`76`) - - -v0.2.2 ------- +- Drop Python 3.9, Support Python 3.14. +- Drop support for i686 Linux. -- Cythonization in setup.py; mostly a development issue. -- Fix for av.InputContainer.size over 2**31. +Features: +- Add ``Filter.Context.process_command()`` method by :gh-user:`caffeinism` in (:pr:`2000`). +- Add packet side-data handling mechanism by :gh-user:`tikuma-lsuhsc` in (:pr:`2003`). +- Implemented set_chapters method by :gh-user:`DE-AI` in (:pr:`2004`). +- Declare free-threaded support and support 3.13t by :gh-user:`ngoldbaum` in (:pr:`2005`). +- Add writable and copyable attachment and data streams by :gh-user:`skeskinen` in (:pr:`2026`). -v0.2.1 ------- - -- Python 3 compatibility! -- Build process fails if missing libraries. -- Fix linking of libavdevices. - - -v0.2.0 ------- - -.. warning:: This version has an issue linking in libavdevices, and very likely - will not work for you. - -It sure has been a long time since this was released, and there was a lot of -arbitrary changes that come with us wrapping an API as we are discovering it. -Changes include, but are not limited to: - -- Audio encoding. -- Exposing planes and buffers. -- Descriptors for channel layouts, video and audio formats, etc.. -- Seeking. -- Many many more properties on all of the objects. -- Device support (e.g. webcams). +Fixes: +- Declare free-threaded support and support 3.13t by :gh-user:`ngoldbaum` in (:pr:`2005`). +- Allow ``None`` in ``FilterContext.push()`` type stub by :gh-user:`velsinki` in (:pr:`2015`). +- Fix typos -v0.1.0 ------- +15.X and Below +-------------- +.. + or see older git commits -- FIRST PUBLIC RELEASE! -- Container/video/audio formats. -- Audio layouts. -- Decoding video/audio/subtitles. -- Encoding video. -- Audio FIFOs and resampling. +`15.X Changelog ` diff --git a/HACKING.rst b/HACKING.rst deleted file mode 100644 index cc8be9008..000000000 --- a/HACKING.rst +++ /dev/null @@ -1,59 +0,0 @@ -Hacking on PyAV -=============== - -The Goal --------- - -The goal of PyAV is to not only wrap FFmpeg in Python and provide complete access to the library for power users, but to make FFmpeg approachable without the need to understand all of the underlying mechanics. - - -Names and Structure -------------------- - -As much as reasonable, PyAV mirrors FFmpeg's structure and naming. Ideally, searching for documentation for ``CodecContext.bit_rate`` leads to ``AVCodecContext.bit_rate`` as well. - -We allow ourselves to depart from FFmpeg to make everything feel more consistent, e.g.: - -- we change a few names to make them more readable, by adding underscores, etc.; -- all of the audio classes are prefixed with ``Audio``, while some of the FFmpeg structs are prefixed with ``Sample`` (e.g. ``AudioFormat`` vs ``AVSampleFormat``). - -We will also sometimes duplicate APIs in order to provide both a low-level and high-level experience, e.g.: - -- Object flags are usually exposed as a :class:`av.enum.EnumFlag` (with FFmpeg names) under a ``flags`` attribute, **and** each flag is also a boolean attribute (with more Pythonic names). - - -Version Compatibility ---------------------- - -We currently support FFmpeg 4.0 through 4.2, on Python 3.5 through 3.8, on Linux, macOS, and Windows. We `continually test `_ these configurations. - -Differences are handled at compile time, in C, by checking against ``LIBAV*_VERSION_INT`` macros. We have not been able to perform this sort of checking in Cython as we have not been able to have it fully remove the code-paths, and so there are missing functions in newer FFmpeg's, and deprecated ones that emit compiler warnings in older FFmpeg's. - -Unfortunately, this means that PyAV is built for the existing FFmpeg, and must be rebuilt when FFmpeg is updated. - -We used to do this detection in small ``*.pyav.h`` headers in the ``include`` directory (and there are still some there as of writing), but the preferred method is to create ``*-shims.c`` files that are cimport-ed by the one module that uses them. - -You can use the same build system as continuous integration for local development:: - - # Prep the environment. - source scripts/activate.sh - - # Build FFmpeg. - ./scripts/build-deps - - # Build PyAV. - make - - # Run the tests. - make test - - -Code Formatting and Linting ---------------------------- - -``isort`` and ``flake8`` are integrated into the continuous integration, and are required to pass for code to be merged into develop. You can run these via ``scripts/test``:: - - ./scripts/test isort - ./scripts/test flake8 - - diff --git a/MANIFEST.in b/MANIFEST.in index 321b65e6d..b87a010d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,5 +3,5 @@ recursive-include av *.pyx *.pxd recursive-include docs *.rst *.py recursive-include examples *.py recursive-include include *.pxd *.h -recursive-include src/av *.c -recursive-include tests *.py +recursive-include src/av *.c *.h +recursive-include tests *.py \ No newline at end of file diff --git a/Makefile b/Makefile index 6cb954dea..4fb974eb2 100644 --- a/Makefile +++ b/Makefile @@ -1,90 +1,39 @@ LDFLAGS ?= "" -CFLAGS ?= "-O0" +CFLAGS ?= "-O0 -Wno-unreachable-code" PYAV_PYTHON ?= python +PYAV_PIP ?= pip PYTHON := $(PYAV_PYTHON) +PIP := $(PYAV_PIP) -.PHONY: default build cythonize clean clean-all info lint test fate-suite test-assets docs +.PHONY: default build clean fate-suite lint test default: build build: + $(PIP) install -U --pre cython setuptools CFLAGS=$(CFLAGS) LDFLAGS=$(LDFLAGS) $(PYTHON) setup.py build_ext --inplace --debug -cythonize: - $(PYTHON) setup.py cythonize - - - -wheel: build-mingw32 - $(PYTHON) setup.py bdist_wheel - -build-mingw32: - # before running, set PKG_CONFIG_PATH to the pkgconfig dir of the ffmpeg build. - # set PKG_CONFIG_PATH=D:\dev\3rd\media-autobuild_suite\local32\bin-video\ffmpegSHARED\lib\pkgconfig - CFLAGS=$(CFLAGS) LDFLAGS=$(LDFLAGS) $(PYTHON) setup.py build_ext --inplace -c mingw32 - mv *.pyd av - - +clean: + - find av -name '*.so' -delete + - rm -rf build + - rm -rf sandbox + - rm -rf src + - make -C docs clean fate-suite: # Grab ALL of the samples from the ffmpeg site. + mkdir -p tests/assets/fate-suite/ rsync -vrltLW rsync://fate-suite.ffmpeg.org/fate-suite/ tests/assets/fate-suite/ lint: - TESTSUITE=flake8 scripts/test - TESTSUITE=isort scripts/test + $(PIP) install -U ruff isort pillow numpy mypy==2.1.0 pytest + ruff format --check av examples tests setup.py + isort --check-only --diff av examples tests + mypy av tests test: - $(PYTHON) setup.py test - - - -vagrant: - vagrant box list | grep -q precise32 || vagrant box add precise32 http://files.vagrantup.com/precise32.box - -vtest: - vagrant ssh -c /vagrant/scripts/vagrant-test - - - -tmp/ffmpeg-git: - @ mkdir -p tmp/ffmpeg-git - git clone --depth=1 git://source.ffmpeg.org/ffmpeg.git tmp/ffmpeg-git - -tmp/Doxyfile: tmp/ffmpeg-git - cp tmp/ffmpeg-git/doc/Doxyfile $@ - echo "GENERATE_TAGFILE = ../tagfile.xml" >> $@ - -tmp/tagfile.xml: tmp/Doxyfile - cd tmp/ffmpeg-git; doxygen ../Doxyfile - -docs: tmp/tagfile.xml - PYTHONPATH=.. make -C docs html - -deploy-docs: docs - ./docs/upload docs - - - - -clean-build: - - rm -rf build - - find av -name '*.so' -delete - -clean-sandbox: - - rm -rf sandbox/201* - - rm sandbox/last - -clean-src: - - rm -rf src - -clean-docs: - - rm tmp/Doxyfile - - rm tmp/tagfile.xml - - make -C docs clean - -clean: clean-build clean-sandbox clean-src -clean-all: clean-build clean-sandbox clean-src clean-docs + $(PIP) install --upgrade cython numpy pillow pytest + $(PYTHON) -m pytest diff --git a/README.md b/README.md index 961b5ff17..8e66d98be 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ PyAV ==== -[![GitHub Test Status][github-tests-badge]][github-tests] \ -[![Gitter Chat][gitter-badge]][gitter] [![Documentation][docs-badge]][docs] \ -[![GitHub][github-badge]][github] [![Python Package Index][pypi-badge]][pypi] [![Conda Forge][conda-badge]][conda] +PyAV is a Pythonic binding for the [FFmpeg][ffmpeg] libraries. We aim to provide all the power and control of the underlying library, but manage the gritty details as much as possible. -PyAV is a Pythonic binding for the [FFmpeg][ffmpeg] libraries. We aim to provide all of the power and control of the underlying library, but manage the gritty details as much as possible. +--- + +[![GitHub Test Status][github-tests-badge]][github-tests] [![Documentation][docs-badge]][docs] [![Python Package Index][pypi-badge]][pypi] [![Conda Forge][conda-badge]][conda] -PyAV is for direct and precise access to your media via containers, streams, packets, codecs, and frames. It exposes a few transformations of that data, and helps you get your data to/from other packages (e.g. Numpy and Pillow). +PyAV is for direct and precise access to your media via containers, streams, packets, codecs, and frames. It exposes a few transformations of that data, and helps you get your data to/from other packages (e.g. NumPy and Pillow). This power does come with some responsibility as working with media is horrendously complicated and PyAV can't abstract it away or make all the best decisions for you. If the `ffmpeg` command does the job without you bending over backwards, PyAV is likely going to be more of a hindrance than a help. @@ -17,66 +17,89 @@ But where you can't work without it, PyAV is a critical tool. Installation ------------ -Due to the complexity of the dependencies, PyAV is not always the easiest Python package to install from source. Since release 8.0.0 binary wheels are provided on [PyPI][pypi] for Linux, Mac and Windows linked against a modern FFmpeg. You can install these wheels by running: +PyAV requires Python 3.11 or later. Binary wheels are provided on [PyPI][pypi] for Linux, macOS, and Windows with FFmpeg bundled. You can install these wheels by running: -``` +```bash pip install av ``` -If you want to use your existing FFmpeg/Libav, the C-source version of PyAV is on [PyPI][pypi] too: +Another way of installing PyAV is via [conda-forge][conda-forge]: +```bash +conda install av -c conda-forge ``` -pip install av --no-binary av -``` + +See the [Conda install][conda-install] docs to get started with Miniconda. + Alternative installation methods -------------------------------- -Another way of installing PyAV is via [conda-forge][conda-forge]: +Due to the complexity of the dependencies, PyAV is not always the easiest Python package to install from source. This release supports FFmpeg 8.x. To build the source distribution against an existing FFmpeg installation on Linux or macOS, run: -``` -conda install av -c conda-forge +> [!WARNING] +> FFmpeg's development files and `pkg-config` must be available on your system. + +```bash +pip install av --no-binary av ``` -See the [Conda quick install][conda-install] docs to get started with (mini)Conda. -And if you want to build from the absolute source (for development or testing): +Installing from source +---------------------- -``` -git clone git@github.com:PyAV-Org/PyAV +On Linux or macOS, build PyAV from a Git checkout with: + +```bash +git clone https://github.com/PyAV-Org/PyAV.git cd PyAV source scripts/activate.sh -# Either install the testing dependencies: -pip install --upgrade -r tests/requirements.txt -# or have it all, including FFmpeg, built/installed for you: +# Build ffmpeg from source. You can skip this step if ffmpeg 8.x is already installed. ./scripts/build-deps -# Build PyAV. +# Build PyAV make + +# Testing +make test + +# Install globally +deactivate +pip install . +``` + +On Windows, use a Conda environment and the FFmpeg development files maintained by the PyAV project: + +```powershell +git clone https://github.com/PyAV-Org/PyAV.git +cd PyAV +conda create --name pyav-dev --channel conda-forge python=3.11 cython setuptools numpy pillow pytest +conda activate pyav-dev +$ffmpegDir = Join-Path $env:CONDA_PREFIX "Library" +python scripts\fetch-vendor.py --config-file scripts\ffmpeg-latest.json $ffmpegDir +python setup.py build_ext --inplace --ffmpeg-dir=$ffmpegDir +python -m pytest ``` --- -Have fun, [read the docs][docs], [come chat with us][gitter], and good luck! +Have fun, [read the docs][docs], [come chat with us][discuss], and good luck! [conda-badge]: https://img.shields.io/conda/vn/conda-forge/av.svg?colorB=CCB39A [conda]: https://anaconda.org/conda-forge/av -[docs-badge]: https://img.shields.io/badge/docs-on%20pyav.org-blue.svg -[docs]: http://pyav.org/docs -[gitter-badge]: https://img.shields.io/gitter/room/nwjs/nw.js.svg?logo=gitter&colorB=cc2b5e -[gitter]: https://gitter.im/PyAV-Org +[docs-badge]: https://img.shields.io/badge/docs-on%20pyav.basswood.io-blue.svg +[docs]: https://pyav.basswood.io [pypi-badge]: https://img.shields.io/pypi/v/av.svg?colorB=CCB39A [pypi]: https://pypi.org/project/av +[discuss]: https://github.com/PyAV-Org/PyAV/discussions [github-tests-badge]: https://github.com/PyAV-Org/PyAV/workflows/tests/badge.svg [github-tests]: https://github.com/PyAV-Org/PyAV/actions?workflow=tests -[github-badge]: https://img.shields.io/badge/dynamic/xml.svg?label=github&url=https%3A%2F%2Fraw.githubusercontent.com%2FPyAV-Org%2FPyAV%2Fdevelop%2FVERSION.txt&query=.&colorB=CCB39A&prefix=v [github]: https://github.com/PyAV-Org/PyAV -[ffmpeg]: http://ffmpeg.org/ +[ffmpeg]: https://ffmpeg.org/ [conda-forge]: https://conda-forge.github.io/ -[conda-install]: https://conda.io/docs/install/quick.html - +[conda-install]: https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html diff --git a/VERSION.txt b/VERSION.txt deleted file mode 100644 index b6c32c94b..000000000 --- a/VERSION.txt +++ /dev/null @@ -1 +0,0 @@ -8.0.4.dev0 diff --git a/av/data/__init__.py b/av/__init__.pxd similarity index 100% rename from av/data/__init__.py rename to av/__init__.pxd diff --git a/av/__init__.py b/av/__init__.py index 7713215b7..964b93891 100644 --- a/av/__init__.py +++ b/av/__init__.py @@ -1,30 +1,73 @@ -# Add the native FFMPEG and MinGW libraries to executable path, so that the -# AV pyd files can find them. -import os -if os.name == 'nt': - os.environ['PATH'] = os.path.abspath(os.path.dirname(__file__)) + os.pathsep + os.environ['PATH'] - -# MUST import the core before anything else in order to initalize the underlying +# MUST import the core before anything else in order to initialize the underlying # library that is being wrapped. -from av._core import time_base, pyav_version as __version__, library_versions +from av._core import time_base, library_versions, ffmpeg_version_info # Capture logging (by importing it). from av import logging -# For convenience, IMPORT ALL OF THE THINGS (that are constructable by the user). +# For convenience, import all common attributes. +from av.about import __version__ +from av.audio.codeccontext import AudioCodecContext from av.audio.fifo import AudioFifo from av.audio.format import AudioFormat from av.audio.frame import AudioFrame from av.audio.layout import AudioLayout from av.audio.resampler import AudioResampler +from av.audio.stream import AudioStream +from av.bitstream import BitStreamFilterContext, bitstream_filters_available from av.codec.codec import Codec, codecs_available from av.codec.context import CodecContext from av.container import open +from av.device import DeviceInfo, enumerate_input_devices, enumerate_output_devices from av.format import ContainerFormat, formats_available from av.packet import Packet +from av.rational import AVRational from av.error import * # noqa: F403; This is limited to exception types. +from av.video.codeccontext import VideoCodecContext from av.video.format import VideoFormat from av.video.frame import VideoFrame +from av.video.stream import VideoStream + +__all__ = ( + "__version__", + "time_base", + "ffmpeg_version_info", + "library_versions", + "AudioCodecContext", + "AudioFifo", + "AudioFormat", + "AudioFrame", + "AudioLayout", + "AudioResampler", + "AudioStream", + "BitStreamFilterContext", + "bitstream_filters_available", + "Codec", + "codecs_available", + "CodecContext", + "open", + "DeviceInfo", + "enumerate_input_devices", + "enumerate_output_devices", + "ContainerFormat", + "formats_available", + "Packet", + "VideoCodecContext", + "VideoFormat", + "VideoFrame", + "VideoStream", +) + + +def get_include() -> str: + """ + Returns the path to the `include` folder to be used when building extensions to av. + """ + import os -# Backwards compatibility -AVError = FFmpegError # noqa: F405 + # Installed package + include_path = os.path.join(os.path.dirname(__file__), "include") + if os.path.exists(include_path): + return include_path + # Running from source directory + return os.path.join(os.path.dirname(__file__), os.pardir, "include") diff --git a/av/__main__.py b/av/__main__.py index a5873a5f9..9e2b9d0ac 100644 --- a/av/__main__.py +++ b/av/__main__.py @@ -1,42 +1,54 @@ -import argparse +from __future__ import annotations +import argparse -def main(): +def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument('--codecs', action='store_true') - parser.add_argument('--version', action='store_true') + parser.add_argument("--codecs", action="store_true") + parser.add_argument("--hwdevices", action="store_true") + parser.add_argument("--hwconfigs", action="store_true") + parser.add_argument("--version", action="store_true") args = parser.parse_args() - # --- - if args.version: - + import av import av._core - print('PyAV v' + av._core.pyav_version) - print('git origin: git@github.com:PyAV-Org/PyAV') - print('git commit:', av._core.pyav_commit) + print(f"PyAV v{av.__version__}") - by_config = {} + by_config: dict = {} for libname, config in sorted(av._core.library_meta.items()): - version = config['version'] + version = config["version"] if version[0] >= 0: by_config.setdefault( - (config['configuration'], config['license']), - [] + (config["configuration"], config["license"]), [] ).append((libname, config)) + for (config, license), libs in sorted(by_config.items()): - print('library configuration:', config) - print('library license:', license) + print("library configuration:", config) + print("library license:", license) for libname, config in libs: - version = config['version'] - print('%-13s %3d.%3d.%3d' % (libname, version[0], version[1], version[2])) + version = config["version"] + print(f"{libname:<13} {version[0]:3d}.{version[1]:3d}.{version[2]:3d}") + + if args.hwdevices: + from av.codec.hwaccel import hwdevices_available + + print("Hardware device types:") + for x in hwdevices_available(): + print(" ", x) + + if args.hwconfigs: + from av.codec.codec import dump_hwconfigs + + dump_hwconfigs() if args.codecs: from av.codec.codec import dump_codecs + dump_codecs() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/av/_core.pxd b/av/_core.pxd new file mode 100644 index 000000000..2d9e95209 --- /dev/null +++ b/av/_core.pxd @@ -0,0 +1,9 @@ +cdef extern from "libswscale/swscale.h" nogil: + cdef unsigned int swscale_version() + cdef const char* swscale_configuration() + cdef const char* swscale_license() + +cdef extern from "libswresample/swresample.h" nogil: + cdef unsigned int swresample_version() + cdef const char* swresample_configuration() + cdef const char* swresample_license() diff --git a/av/_core.py b/av/_core.py new file mode 100644 index 000000000..6ad38df5b --- /dev/null +++ b/av/_core.py @@ -0,0 +1,65 @@ +import cython +import cython.cimports.libav as lib + +lib.avdevice_register_all() + +# Exports. +time_base = lib.AV_TIME_BASE + + +@cython.cfunc +def decode_version(v): + if v < 0: + return (-1, -1, -1) + + major: cython.int = (v >> 16) & 0xFF + minor: cython.int = (v >> 8) & 0xFF + micro: cython.int = (v) & 0xFF + return (major, minor, micro) + + +# Return an informative version string. +# This usually is the actual release version number or a git commit +# description. This string has no fixed format and can change any time. It +# should never be parsed by code. +ffmpeg_version_info = lib.av_version_info() + +library_meta = { + "libavutil": dict( + version=decode_version(lib.avutil_version()), + configuration=lib.avutil_configuration(), + license=lib.avutil_license(), + ), + "libavcodec": dict( + version=decode_version(lib.avcodec_version()), + configuration=lib.avcodec_configuration(), + license=lib.avcodec_license(), + ), + "libavformat": dict( + version=decode_version(lib.avformat_version()), + configuration=lib.avformat_configuration(), + license=lib.avformat_license(), + ), + "libavdevice": dict( + version=decode_version(lib.avdevice_version()), + configuration=lib.avdevice_configuration(), + license=lib.avdevice_license(), + ), + "libavfilter": dict( + version=decode_version(lib.avfilter_version()), + configuration=lib.avfilter_configuration(), + license=lib.avfilter_license(), + ), + "libswscale": dict( + version=decode_version(swscale_version()), + configuration=swscale_configuration(), + license=swscale_license(), + ), + "libswresample": dict( + version=decode_version(swresample_version()), + configuration=swresample_configuration(), + license=swresample_license(), + ), +} + +library_versions = {name: meta["version"] for name, meta in library_meta.items()} diff --git a/av/_core.pyi b/av/_core.pyi new file mode 100644 index 000000000..0ee0a5626 --- /dev/null +++ b/av/_core.pyi @@ -0,0 +1,12 @@ +from typing import TypedDict + +class _Meta(TypedDict): + version: tuple[int, int, int] + configuration: str + license: str + +library_meta: dict[str, _Meta] +library_versions: dict[str, tuple[int, int, int]] +ffmpeg_version_info: str + +time_base: int diff --git a/av/_core.pyx b/av/_core.pyx deleted file mode 100644 index e126580d7..000000000 --- a/av/_core.pyx +++ /dev/null @@ -1,61 +0,0 @@ -cimport libav as lib - - -# Initialise libraries. -lib.avformat_network_init() -lib.avdevice_register_all() - -# Exports. -time_base = lib.AV_TIME_BASE - -pyav_version = lib.PYAV_VERSION_STR -pyav_commit = lib.PYAV_COMMIT_STR - - -cdef decode_version(v): - if v < 0: - return (-1, -1, -1) - cdef int major = (v >> 16) & 0xff - cdef int minor = (v >> 8) & 0xff - cdef int micro = (v) & 0xff - return (major, minor, micro) - -library_meta = { - 'libavutil': dict( - version=decode_version(lib.avutil_version()), - configuration=lib.avutil_configuration(), - license=lib.avutil_license() - ), - 'libavcodec': dict( - version=decode_version(lib.avcodec_version()), - configuration=lib.avcodec_configuration(), - license=lib.avcodec_license() - ), - 'libavformat': dict( - version=decode_version(lib.avformat_version()), - configuration=lib.avformat_configuration(), - license=lib.avformat_license() - ), - 'libavdevice': dict( - version=decode_version(lib.avdevice_version()), - configuration=lib.avdevice_configuration(), - license=lib.avdevice_license() - ), - 'libavfilter': dict( - version=decode_version(lib.avfilter_version()), - configuration=lib.avfilter_configuration(), - license=lib.avfilter_license() - ), - 'libswscale': dict( - version=decode_version(lib.swscale_version()), - configuration=lib.swscale_configuration(), - license=lib.swscale_license() - ), - 'libswresample': dict( - version=decode_version(lib.swresample_version()), - configuration=lib.swresample_configuration(), - license=lib.swresample_license() - ), -} - -library_versions = {name: meta['version'] for name, meta in library_meta.items()} diff --git a/av/about.py b/av/about.py new file mode 100644 index 000000000..c6a8b8ed8 --- /dev/null +++ b/av/about.py @@ -0,0 +1 @@ +__version__ = "18.0.0" diff --git a/include/libav.pyav.h b/av/audio/__init__.pxd similarity index 100% rename from include/libav.pyav.h rename to av/audio/__init__.pxd diff --git a/av/audio/__init__.py b/av/audio/__init__.py index 2986dd37c..f57d62046 100644 --- a/av/audio/__init__.py +++ b/av/audio/__init__.py @@ -1 +1,2 @@ -from .frame import AudioFrame +from .frame import AudioFrame as AudioFrame +from .stream import AudioStream as AudioStream diff --git a/av/audio/__init__.pyi b/av/audio/__init__.pyi new file mode 100644 index 000000000..b6a5c78e0 --- /dev/null +++ b/av/audio/__init__.pyi @@ -0,0 +1,122 @@ +from typing import Literal + +from .frame import AudioFrame +from .stream import AudioStream + +# FFmpeg 8.1 encoders and the codec descriptor aliases that resolve to them. +_AudioCodecName = Literal[ + "aac", + "aac_at", + "aac_mf", + "ac3", + "ac3_fixed", + "ac3_mf", + "adpcm_adx", + "adpcm_argo", + "adpcm_g722", + "adpcm_g726", + "adpcm_g726le", + "adpcm_ima_alp", + "adpcm_ima_amv", + "adpcm_ima_apm", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ima_ws", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "alac_at", + "amr_nb", + "amr_wb", + "anull", + "aptx", + "aptx_hd", + "codec2", + "comfortnoise", + "dca", + "dfpwm", + "dts", + "eac3", + "flac", + "g722", + "g723_1", + "g726", + "g726le", + "gsm", + "gsm_ms", + "ilbc", + "ilbc_at", + "lc3", + "libcodec2", + "libfdk_aac", + "libgsm", + "libgsm_ms", + "libilbc", + "liblc3", + "libmp3lame", + "libopencore_amrnb", + "libopus", + "libshine", + "libspeex", + "libtwolame", + "libvo_amrwbenc", + "libvorbis", + "mlp", + "mp2", + "mp2fixed", + "mp3", + "mp3_mf", + "nellymoser", + "opus", + "pcm_alaw", + "pcm_alaw_at", + "pcm_bluray", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_mulaw_at", + "pcm_s16be", + "pcm_s16be_planar", + "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "ra_144", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "speex", + "truehd", + "tta", + "vorbis", + "wavpack", + "wmav1", + "wmav2", +] + +__all__ = ("AudioFrame", "AudioStream") diff --git a/av/audio/codeccontext.pxd b/av/audio/codeccontext.pxd index 23d1a62a9..55ad15e9f 100644 --- a/av/audio/codeccontext.pxd +++ b/av/audio/codeccontext.pxd @@ -1,15 +1,11 @@ -from av.audio.fifo cimport AudioFifo from av.audio.frame cimport AudioFrame from av.audio.resampler cimport AudioResampler from av.codec.context cimport CodecContext cdef class AudioCodecContext(CodecContext): - # Hold onto the frames that we will decode until we have a full one. cdef AudioFrame next_frame - # For encoding. cdef AudioResampler resampler - cdef AudioFifo fifo diff --git a/av/audio/codeccontext.py b/av/audio/codeccontext.py new file mode 100644 index 000000000..86ac107cf --- /dev/null +++ b/av/audio/codeccontext.py @@ -0,0 +1,109 @@ +import cython +from cython.cimports import libav as lib +from cython.cimports.av.audio.format import AudioFormat, get_audio_format +from cython.cimports.av.audio.frame import AudioFrame, alloc_audio_frame +from cython.cimports.av.audio.layout import AudioLayout, get_audio_layout +from cython.cimports.av.frame import Frame +from cython.cimports.av.packet import Packet + + +@cython.final +@cython.cclass +class AudioCodecContext(CodecContext): + @cython.cfunc + def _prepare_frames_for_encode(self, input_frame: Frame | None) -> list: + frame: AudioFrame | None = input_frame + allow_var_frame_size: cython.bint = ( + self.ptr.codec.capabilities & lib.AV_CODEC_CAP_VARIABLE_FRAME_SIZE + ) + + # Note that the resampler will simply return an input frame if there is + # no resampling to be done. The control flow was just a little easier this way. + if not self.resampler: + self.resampler = AudioResampler( + format=self.format, + layout=self.layout, + rate=self.ptr.sample_rate, + frame_size=None if allow_var_frame_size else self.ptr.frame_size, + ) + frames = self.resampler.resample(frame) + if input_frame is None: + frames.append(None) # flush if input frame is None + + return frames + + @cython.cfunc + def _alloc_next_frame(self) -> Frame: + return alloc_audio_frame() + + @cython.cfunc + def _setup_decoded_frame(self, frame: Frame, packet: Packet): + CodecContext._setup_decoded_frame(self, frame, packet) + aframe: AudioFrame = frame + aframe._init_user_attributes() + + @property + def frame_size(self): + """ + Number of samples per channel in an audio frame. + + :type: int + """ + return self.ptr.frame_size + + @property + def sample_rate(self): + """ + Sample rate of the audio data, in samples per second. + + :type: int + """ + return self.ptr.sample_rate + + @sample_rate.setter + def sample_rate(self, value: cython.int): + self._assert_not_open("sample_rate") + self.ptr.sample_rate = value + + @property + def rate(self): + """Another name for :attr:`sample_rate`.""" + return self.sample_rate + + @rate.setter + def rate(self, value): + self.sample_rate = value + + @property + def channels(self): + return self.layout.nb_channels + + @property + def layout(self): + """ + The audio channel layout. + + :type: AudioLayout + """ + return get_audio_layout(self.ptr.ch_layout) + + @layout.setter + def layout(self, value): + self._assert_not_open("layout") + layout: AudioLayout = AudioLayout(value) + self.ptr.ch_layout = layout.layout + + @property + def format(self): + """ + The audio sample format. + + :type: AudioFormat + """ + return get_audio_format(self.ptr.sample_fmt) + + @format.setter + def format(self, value): + self._assert_not_open("format") + format: AudioFormat = AudioFormat(value) + self.ptr.sample_fmt = format.sample_fmt diff --git a/av/audio/codeccontext.pyi b/av/audio/codeccontext.pyi new file mode 100644 index 000000000..e871db698 --- /dev/null +++ b/av/audio/codeccontext.pyi @@ -0,0 +1,30 @@ +from collections.abc import Iterator +from typing import Literal + +from av.codec.context import CodecContext +from av.packet import Packet + +from .format import AudioFormat +from .frame import AudioFrame +from .layout import AudioLayout + +class _Format: + def __get__(self, i: object | None, owner: type | None = None) -> AudioFormat: ... + def __set__(self, instance: object, value: AudioFormat | str) -> None: ... + +class _Layout: + def __get__(self, i: object | None, owner: type | None = None) -> AudioLayout: ... + def __set__(self, instance: object, value: AudioLayout | str) -> None: ... + +class AudioCodecContext(CodecContext): + frame_size: int + sample_rate: int + rate: int + type: Literal["audio"] + format: _Format + layout: _Layout + @property + def channels(self) -> int: ... + def encode(self, frame: AudioFrame | None = None) -> list[Packet]: ... + def encode_lazy(self, frame: AudioFrame | None = None) -> Iterator[Packet]: ... + def decode(self, packet: Packet | None = None) -> list[AudioFrame]: ... diff --git a/av/audio/codeccontext.pyx b/av/audio/codeccontext.pyx deleted file mode 100644 index 0109f95ff..000000000 --- a/av/audio/codeccontext.pyx +++ /dev/null @@ -1,131 +0,0 @@ -cimport libav as lib - -from av.audio.format cimport AudioFormat, get_audio_format -from av.audio.frame cimport AudioFrame, alloc_audio_frame -from av.audio.layout cimport AudioLayout, get_audio_layout -from av.error cimport err_check -from av.frame cimport Frame -from av.packet cimport Packet - - -cdef class AudioCodecContext(CodecContext): - - cdef _init(self, lib.AVCodecContext *ptr, const lib.AVCodec *codec): - CodecContext._init(self, ptr, codec) - - # Sometimes there isn't a layout set, but there are a number of - # channels. Assume it is the default layout. - # TODO: Put this behind `not bare_metal`. - # TODO: Do this more efficiently. - if self.ptr.channels and not self.ptr.channel_layout: - self.ptr.channel_layout = get_audio_layout(self.ptr.channels, 0).layout - - cdef _set_default_time_base(self): - self.ptr.time_base.num = 1 - self.ptr.time_base.den = self.ptr.sample_rate - - cdef _prepare_frames_for_encode(self, Frame input_frame): - - cdef AudioFrame frame = input_frame - - # Resample. A None frame will flush the resampler, and then the fifo (if used). - # Note that the resampler will simply return an input frame if there is - # no resampling to be done. The control flow was just a little easier this way. - if not self.resampler: - self.resampler = AudioResampler( - self.format, - self.layout, - self.ptr.sample_rate - ) - frame = self.resampler.resample(frame) - - cdef bint is_flushing = input_frame is None - cdef bint use_fifo = not (self.ptr.codec.capabilities & lib.AV_CODEC_CAP_VARIABLE_FRAME_SIZE) - - if use_fifo: - if not self.fifo: - self.fifo = AudioFifo() - if frame is not None: - self.fifo.write(frame) - frames = self.fifo.read_many(self.ptr.frame_size, partial=is_flushing) - if is_flushing: - frames.append(None) - else: - frames = [frame] - - return frames - - cdef Frame _alloc_next_frame(self): - return alloc_audio_frame() - - cdef _setup_decoded_frame(self, Frame frame, Packet packet): - CodecContext._setup_decoded_frame(self, frame, packet) - cdef AudioFrame aframe = frame - aframe._init_user_attributes() - - property frame_size: - """ - Number of samples per channel in an audio frame. - - :type: int - """ - def __get__(self): return self.ptr.frame_size - - property sample_rate: - """ - Sample rate of the audio data, in samples per second. - - :type: int - """ - def __get__(self): - return self.ptr.sample_rate - - def __set__(self, int value): - self.ptr.sample_rate = value - - property rate: - """Another name for :attr:`sample_rate`.""" - def __get__(self): - return self.sample_rate - - def __set__(self, value): - self.sample_rate = value - - # TODO: Integrate into AudioLayout. - property channels: - def __get__(self): - return self.ptr.channels - - def __set__(self, value): - self.ptr.channels = value - self.ptr.channel_layout = lib.av_get_default_channel_layout(value) - property channel_layout: - def __get__(self): - return self.ptr.channel_layout - - property layout: - """ - The audio channel layout. - - :type: AudioLayout - """ - def __get__(self): - return get_audio_layout(self.ptr.channels, self.ptr.channel_layout) - - def __set__(self, value): - cdef AudioLayout layout = AudioLayout(value) - self.ptr.channel_layout = layout.layout - self.ptr.channels = layout.nb_channels - - property format: - """ - The audio sample format. - - :type: AudioFormat - """ - def __get__(self): - return get_audio_format(self.ptr.sample_fmt) - - def __set__(self, value): - cdef AudioFormat format = AudioFormat(value) - self.ptr.sample_fmt = format.sample_fmt diff --git a/av/audio/fifo.pxd b/av/audio/fifo.pxd index cf3a9dbec..0ace5e4b1 100644 --- a/av/audio/fifo.pxd +++ b/av/audio/fifo.pxd @@ -1,5 +1,5 @@ -from libc.stdint cimport int64_t, uint64_t cimport libav as lib +from libc.stdint cimport int64_t, uint64_t from av.audio.frame cimport AudioFrame diff --git a/av/audio/fifo.py b/av/audio/fifo.py new file mode 100644 index 000000000..00db125b7 --- /dev/null +++ b/av/audio/fifo.py @@ -0,0 +1,222 @@ +import cython +from cython.cimports.av.audio.frame import alloc_audio_frame +from cython.cimports.av.error import err_check + + +@cython.final +@cython.cclass +class AudioFifo: + """A simple audio sample FIFO (First In First Out) buffer.""" + + def __repr__(self): + try: + result = ( + f"" + ) + except AttributeError: + result = ( + f"" + ) + return result + + def __dealloc__(self): + if self.ptr: + lib.av_audio_fifo_free(self.ptr) + + @cython.ccall + def write(self, frame: AudioFrame | None): + """write(frame) + + Push a frame of samples into the queue. + + :param AudioFrame frame: The frame of samples to push. + + The FIFO will remember the attributes from the first frame, and use those + to populate all output frames. + + If there is a :attr:`~.Frame.pts` and :attr:`~.Frame.time_base` and + :attr:`~.AudioFrame.sample_rate`, then the FIFO will assert that the incoming + timestamps are continuous. + + """ + + if frame is None: + raise TypeError("AudioFifo must be given an AudioFrame.") + + if not frame.ptr.nb_samples: + return + + if not self.ptr: + # Hold onto a copy of the attributes of the first frame to populate + # output frames with. + self.template = alloc_audio_frame() + self.template._copy_internal_attributes(frame) + self.template._init_user_attributes() + + # Figure out our "time_base". + if frame._time_base.num and frame.ptr.sample_rate: + self.pts_per_sample = frame._time_base.den / float(frame._time_base.num) + self.pts_per_sample /= frame.ptr.sample_rate + else: + self.pts_per_sample = 0 + + self.ptr = lib.av_audio_fifo_alloc( + cython.cast(lib.AVSampleFormat, frame.ptr.format), + frame.layout.nb_channels, + frame.ptr.nb_samples + * 2, # Just a default number of samples; it will adjust. + ) + + if not self.ptr: + raise RuntimeError("Could not allocate AVAudioFifo.") + + # Make sure nothing changed. + elif ( + frame.ptr.format != self.template.ptr.format + or frame.ptr.sample_rate != self.template.ptr.sample_rate + or ( + frame._time_base.num + and self.template._time_base.num + and ( + frame._time_base.num != self.template._time_base.num + or frame._time_base.den != self.template._time_base.den + ) + ) + ): + raise ValueError("Frame does not match AudioFifo parameters.") + + # Assert that the PTS are what we expect. + expected_pts = cython.declare(int64_t) + if self.pts_per_sample and frame.ptr.pts != lib.AV_NOPTS_VALUE: + expected_pts = cython.cast( + int64_t, self.pts_per_sample * self.samples_written + ) + if frame.ptr.pts != expected_pts: + raise ValueError( + f"Frame.pts ({frame.ptr.pts}) != expected ({expected_pts}); " + "fix or set to None." + ) + + err_check( + lib.av_audio_fifo_write( + self.ptr, + cython.cast(cython.pointer[cython.p_void], frame.ptr.extended_data), + frame.ptr.nb_samples, + ) + ) + + self.samples_written += frame.ptr.nb_samples + + @cython.ccall + def read(self, samples: cython.int = 0, partial: cython.bint = False): + """read(samples=0, partial=False) + + Read samples from the queue. + + :param int samples: The number of samples to pull; 0 gets all. + :param bool partial: Allow returning less than requested. + :returns: New :class:`AudioFrame` or ``None`` (if empty). + + If the incoming frames had valid a :attr:`~.Frame.time_base`, + :attr:`~.AudioFrame.sample_rate` and :attr:`~.Frame.pts`, the returned frames + will have accurate timing. + + """ + + if not self.ptr: + return + + buffered_samples: cython.int = lib.av_audio_fifo_size(self.ptr) + if buffered_samples < 1: + return + + samples = samples or buffered_samples + + if buffered_samples < samples: + if partial: + samples = buffered_samples + else: + return + + frame: AudioFrame = alloc_audio_frame() + frame._copy_internal_attributes(self.template) + frame._init( + cython.cast(lib.AVSampleFormat, self.template.ptr.format), + cython.cast(lib.AVChannelLayout, self.template.ptr.ch_layout), + samples, + 1, # Align? + ) + + err_check( + lib.av_audio_fifo_read( + self.ptr, + cython.cast(cython.pointer[cython.p_void], frame.ptr.extended_data), + samples, + ) + ) + + if self.pts_per_sample: + frame.ptr.pts = cython.cast( + uint64_t, self.pts_per_sample * self.samples_read + ) + else: + frame.ptr.pts = lib.AV_NOPTS_VALUE + + self.samples_read += samples + return frame + + @cython.ccall + def read_many(self, samples: cython.int, partial: cython.bint = False): + """read_many(samples, partial=False) + + Read as many frames as we can. + + :param int samples: How large for the frames to be. + :param bool partial: If we should return a partial frame. + :returns: A ``list`` of :class:`AudioFrame`. + + """ + + frame: AudioFrame + frames: list = [] + while True: + frame = self.read(samples, partial=partial) + if frame is not None: + frames.append(frame) + else: + break + + return frames + + @property + def format(self): + """The :class:`.AudioFormat` of this FIFO.""" + if not self.ptr: + raise AttributeError( + f"'{__name__}.AudioFifo' object has no attribute 'format'" + ) + return self.template.format + + @property + def layout(self): + """The :class:`.AudioLayout` of this FIFO.""" + if not self.ptr: + raise AttributeError( + f"'{__name__}.AudioFifo' object has no attribute 'layout'" + ) + return self.template.layout + + @property + def sample_rate(self): + if not self.ptr: + raise AttributeError( + f"'{__name__}.AudioFifo' object has no attribute 'sample_rate'" + ) + return self.template.sample_rate + + @property + def samples(self): + """Number of audio samples (per channel) in the buffer.""" + return lib.av_audio_fifo_size(self.ptr) if self.ptr else 0 diff --git a/av/audio/fifo.pyi b/av/audio/fifo.pyi new file mode 100644 index 000000000..085ed4bba --- /dev/null +++ b/av/audio/fifo.pyi @@ -0,0 +1,22 @@ +from .format import AudioFormat +from .frame import AudioFrame +from .layout import AudioLayout + +class AudioFifo: + def write(self, frame: AudioFrame) -> None: ... + def read(self, samples: int = 0, partial: bool = False) -> AudioFrame | None: ... + def read_many(self, samples: int, partial: bool = False) -> list[AudioFrame]: ... + @property + def format(self) -> AudioFormat: ... + @property + def layout(self) -> AudioLayout: ... + @property + def sample_rate(self) -> int: ... + @property + def samples(self) -> int: ... + @property + def samples_written(self) -> int: ... + @property + def samples_read(self) -> int: ... + @property + def pts_per_sample(self) -> float: ... diff --git a/av/audio/fifo.pyx b/av/audio/fifo.pyx deleted file mode 100644 index 6d1d17bd7..000000000 --- a/av/audio/fifo.pyx +++ /dev/null @@ -1,188 +0,0 @@ -from av.audio.format cimport get_audio_format -from av.audio.frame cimport alloc_audio_frame -from av.audio.layout cimport get_audio_layout -from av.error cimport err_check - - -cdef class AudioFifo: - - """A simple audio sample FIFO (First In First Out) buffer.""" - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.samples, - self.sample_rate, - self.layout, - self.format, - id(self), - ) - - def __dealloc__(self): - if self.ptr: - lib.av_audio_fifo_free(self.ptr) - - cpdef write(self, AudioFrame frame): - """write(frame) - - Push a frame of samples into the queue. - - :param AudioFrame frame: The frame of samples to push. - - The FIFO will remember the attributes from the first frame, and use those - to populate all output frames. - - If there is a :attr:`~.Frame.pts` and :attr:`~.Frame.time_base` and - :attr:`~.AudioFrame.sample_rate`, then the FIFO will assert that the incoming - timestamps are continuous. - - """ - - if frame is None: - raise TypeError('AudioFifo must be given an AudioFrame.') - - if not frame.ptr.nb_samples: - return - - if not self.ptr: - - # Hold onto a copy of the attributes of the first frame to populate - # output frames with. - self.template = alloc_audio_frame() - self.template._copy_internal_attributes(frame) - self.template._init_user_attributes() - - # Figure out our "time_base". - if frame._time_base.num and frame.ptr.sample_rate: - self.pts_per_sample = frame._time_base.den / float(frame._time_base.num) - self.pts_per_sample /= frame.ptr.sample_rate - else: - self.pts_per_sample = 0 - - self.ptr = lib.av_audio_fifo_alloc( - frame.ptr.format, - len(frame.layout.channels), # TODO: Can we safely use frame.ptr.nb_channels? - frame.ptr.nb_samples * 2, # Just a default number of samples; it will adjust. - ) - - if not self.ptr: - raise RuntimeError('Could not allocate AVAudioFifo.') - - # Make sure nothing changed. - elif ( - frame.ptr.format != self.template.ptr.format or - frame.ptr.channel_layout != self.template.ptr.channel_layout or - frame.ptr.sample_rate != self.template.ptr.sample_rate or - (frame._time_base.num and self.template._time_base.num and ( - frame._time_base.num != self.template._time_base.num or - frame._time_base.den != self.template._time_base.den - )) - ): - raise ValueError('Frame does not match AudioFifo parameters.') - - # Assert that the PTS are what we expect. - cdef int64_t expected_pts - if self.pts_per_sample and frame.ptr.pts != lib.AV_NOPTS_VALUE: - expected_pts = (self.pts_per_sample * self.samples_written) - if frame.ptr.pts != expected_pts: - raise ValueError('Frame.pts (%d) != expected (%d); fix or set to None.' % (frame.ptr.pts, expected_pts)) - - err_check(lib.av_audio_fifo_write( - self.ptr, - frame.ptr.extended_data, - frame.ptr.nb_samples, - )) - - self.samples_written += frame.ptr.nb_samples - - cpdef read(self, int samples=0, bint partial=False): - """read(samples=0, partial=False) - - Read samples from the queue. - - :param int samples: The number of samples to pull; 0 gets all. - :param bool partial: Allow returning less than requested. - :returns: New :class:`AudioFrame` or ``None`` (if empty). - - If the incoming frames had valid a :attr:`~.Frame.time_base`, - :attr:`~.AudioFrame.sample_rate` and :attr:`~.Frame.pts`, the returned frames - will have accurate timing. - - """ - - if not self.ptr: - return - - cdef int buffered_samples = lib.av_audio_fifo_size(self.ptr) - if buffered_samples < 1: - return - - samples = samples or buffered_samples - - if buffered_samples < samples: - if partial: - samples = buffered_samples - else: - return - - cdef AudioFrame frame = alloc_audio_frame() - frame._copy_internal_attributes(self.template) - frame._init( - self.template.ptr.format, - self.template.ptr.channel_layout, - samples, - 1, # Align? - ) - - err_check(lib.av_audio_fifo_read( - self.ptr, - frame.ptr.extended_data, - samples, - )) - - if self.pts_per_sample: - frame.ptr.pts = (self.pts_per_sample * self.samples_read) - else: - frame.ptr.pts = lib.AV_NOPTS_VALUE - - self.samples_read += samples - - return frame - - cpdef read_many(self, int samples, bint partial=False): - """read_many(samples, partial=False) - - Read as many frames as we can. - - :param int samples: How large for the frames to be. - :param bool partial: If we should return a partial frame. - :returns: A ``list`` of :class:`AudioFrame`. - - """ - - cdef AudioFrame frame - frames = [] - while True: - frame = self.read(samples, partial=partial) - if frame is not None: - frames.append(frame) - else: - break - return frames - - property format: - """The :class:`.AudioFormat` of this FIFO.""" - def __get__(self): - return self.template.format - property layout: - """The :class:`.AudioLayout` of this FIFO.""" - def __get__(self): - return self.template.layout - property sample_rate: - def __get__(self): - return self.template.sample_rate - - property samples: - """Number of audio samples (per channel) in the buffer.""" - def __get__(self): - return lib.av_audio_fifo_size(self.ptr) if self.ptr else 0 diff --git a/av/audio/format.pxd b/av/audio/format.pxd index 5090f6886..c4d4bc552 100644 --- a/av/audio/format.pxd +++ b/av/audio/format.pxd @@ -1,11 +1,7 @@ cimport libav as lib -cdef class AudioFormat(object): - +cdef class AudioFormat: cdef lib.AVSampleFormat sample_fmt - cdef _init(self, lib.AVSampleFormat sample_fmt) - - cdef AudioFormat get_audio_format(lib.AVSampleFormat format) diff --git a/av/audio/format.py b/av/audio/format.py new file mode 100644 index 000000000..d398f6924 --- /dev/null +++ b/av/audio/format.py @@ -0,0 +1,145 @@ +import sys + +import cython + +container_format_postfix = cython.declare( + str, "le" if sys.byteorder == "little" else "be" +) +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def get_audio_format(c_format: lib.AVSampleFormat) -> AudioFormat: + """Get an AudioFormat without going through a string.""" + + if c_format < 0: + return None + + format: AudioFormat = AudioFormat(_cinit_bypass_sentinel) + format.sample_fmt = c_format + return format + + +@cython.final +@cython.cclass +class AudioFormat: + """Descriptor of audio formats.""" + + def __cinit__(self, name): + if name is _cinit_bypass_sentinel: + return + + sample_fmt: lib.AVSampleFormat + if isinstance(name, AudioFormat): + sample_fmt = cython.cast(AudioFormat, name).sample_fmt + else: + sample_fmt = lib.av_get_sample_fmt(name) + + if sample_fmt < 0: + raise ValueError(f"Not a sample format: {name!r}") + + self.sample_fmt = sample_fmt + + def __repr__(self): + return f"" + + @property + def name(self): + """Canonical name of the sample format. + + >>> AudioFormat('s16p').name + 's16p' + + """ + return lib.av_get_sample_fmt_name(self.sample_fmt) + + @property + def bytes(self): + """Number of bytes per sample. + + >>> AudioFormat('s16p').bytes + 2 + + """ + return lib.av_get_bytes_per_sample(self.sample_fmt) + + @property + def bits(self): + """Number of bits per sample. + + >>> AudioFormat('s16p').bits + 16 + + """ + return lib.av_get_bytes_per_sample(self.sample_fmt) << 3 + + @property + def is_planar(self): + """Is this a planar format? + + Strictly opposite of :attr:`is_packed`. + + """ + return bool(lib.av_sample_fmt_is_planar(self.sample_fmt)) + + @property + def is_packed(self): + """Is this a packed format? + + Strictly opposite of :attr:`is_planar`. + + """ + return not lib.av_sample_fmt_is_planar(self.sample_fmt) + + @property + def planar(self): + """The planar variant of this format. + + Is itself when planar: + + >>> fmt = AudioFormat('s16p') + >>> fmt.planar is fmt + True + + """ + if self.is_planar: + return self + return get_audio_format(lib.av_get_planar_sample_fmt(self.sample_fmt)) + + @property + def packed(self): + """The packed variant of this format. + + Is itself when packed: + + >>> fmt = AudioFormat('s16') + >>> fmt.packed is fmt + True + + """ + if self.is_packed: + return self + return get_audio_format(lib.av_get_packed_sample_fmt(self.sample_fmt)) + + @property + def container_name(self): + """The name of a :class:`ContainerFormat` which directly accepts this data. + + :raises ValueError: when planar, since there are no such containers. + + """ + if self.is_planar: + raise ValueError("no planar container formats") + + if self.sample_fmt == lib.AV_SAMPLE_FMT_U8: + return "u8" + elif self.sample_fmt == lib.AV_SAMPLE_FMT_S16: + return "s16" + container_format_postfix + elif self.sample_fmt == lib.AV_SAMPLE_FMT_S32: + return "s32" + container_format_postfix + elif self.sample_fmt == lib.AV_SAMPLE_FMT_FLT: + return "f32" + container_format_postfix + elif self.sample_fmt == lib.AV_SAMPLE_FMT_DBL: + return "f64" + container_format_postfix + + raise ValueError("unknown layout") diff --git a/av/audio/format.pyi b/av/audio/format.pyi new file mode 100644 index 000000000..5f7e322ed --- /dev/null +++ b/av/audio/format.pyi @@ -0,0 +1,11 @@ +class AudioFormat: + name: str + bytes: int + bits: int + is_planar: bool + is_packed: bool + planar: AudioFormat + packed: AudioFormat + container_name: str + + def __init__(self, name: str | AudioFormat) -> None: ... diff --git a/av/audio/format.pyx b/av/audio/format.pyx deleted file mode 100644 index 8a2aa20a2..000000000 --- a/av/audio/format.pyx +++ /dev/null @@ -1,142 +0,0 @@ -import sys - - -cdef str container_format_postfix = 'le' if sys.byteorder == 'little' else 'be' - - -cdef object _cinit_bypass_sentinel - -cdef AudioFormat get_audio_format(lib.AVSampleFormat c_format): - """Get an AudioFormat without going through a string.""" - cdef AudioFormat format = AudioFormat.__new__(AudioFormat, _cinit_bypass_sentinel) - format._init(c_format) - return format - - -cdef class AudioFormat(object): - - """Descriptor of audio formats.""" - - def __cinit__(self, name): - - if name is _cinit_bypass_sentinel: - return - - cdef lib.AVSampleFormat sample_fmt - if isinstance(name, AudioFormat): - sample_fmt = (name).sample_fmt - else: - sample_fmt = lib.av_get_sample_fmt(name) - - if sample_fmt < 0: - raise ValueError('Not a sample format: %r' % name) - - self._init(sample_fmt) - - cdef _init(self, lib.AVSampleFormat sample_fmt): - self.sample_fmt = sample_fmt - - def __repr__(self): - return '' % (self.name) - - property name: - """Canonical name of the sample format. - - >>> SampleFormat('s16p').name - 's16p' - - """ - def __get__(self): - return lib.av_get_sample_fmt_name(self.sample_fmt) - - property bytes: - """Number of bytes per sample. - - >>> SampleFormat('s16p').bytes - 2 - - """ - def __get__(self): - return lib.av_get_bytes_per_sample(self.sample_fmt) - - property bits: - """Number of bits per sample. - - >>> SampleFormat('s16p').bits - 16 - - """ - def __get__(self): - return lib.av_get_bytes_per_sample(self.sample_fmt) << 3 - - property is_planar: - """Is this a planar format? - - Strictly opposite of :attr:`is_packed`. - - """ - def __get__(self): - return bool(lib.av_sample_fmt_is_planar(self.sample_fmt)) - - property is_packed: - """Is this a planar format? - - Strictly opposite of :attr:`is_planar`. - - """ - def __get__(self): - return not lib.av_sample_fmt_is_planar(self.sample_fmt) - - property planar: - """The planar variant of this format. - - Is itself when planar: - - >>> fmt = Format('s16p') - >>> fmt.planar is fmt - True - - """ - def __get__(self): - if self.is_planar: - return self - return get_audio_format(lib.av_get_planar_sample_fmt(self.sample_fmt)) - - property packed: - """The packed variant of this format. - - Is itself when packed: - - >>> fmt = Format('s16') - >>> fmt.packed is fmt - True - - """ - def __get__(self): - if self.is_packed: - return self - return get_audio_format(lib.av_get_packed_sample_fmt(self.sample_fmt)) - - property container_name: - """The name of a :class:`ContainerFormat` which directly accepts this data. - - :raises ValueError: when planar, since there are no such containers. - - """ - def __get__(self): - - if self.is_planar: - raise ValueError('no planar container formats') - - if self.sample_fmt == lib.AV_SAMPLE_FMT_U8: - return 'u8' - elif self.sample_fmt == lib.AV_SAMPLE_FMT_S16: - return 's16' + container_format_postfix - elif self.sample_fmt == lib.AV_SAMPLE_FMT_S32: - return 's32' + container_format_postfix - elif self.sample_fmt == lib.AV_SAMPLE_FMT_FLT: - return 'f32' + container_format_postfix - elif self.sample_fmt == lib.AV_SAMPLE_FMT_DBL: - return 'f64' + container_format_postfix - - raise ValueError('unknown layout') diff --git a/av/audio/frame.pxd b/av/audio/frame.pxd index a438fe627..398d76d33 100644 --- a/av/audio/frame.pxd +++ b/av/audio/frame.pxd @@ -1,5 +1,5 @@ -from libc.stdint cimport uint8_t, uint64_t cimport libav as lib +from libc.stdint cimport uint8_t, uint64_t from av.audio.format cimport AudioFormat from av.audio.layout cimport AudioLayout @@ -7,7 +7,6 @@ from av.frame cimport Frame cdef class AudioFrame(Frame): - # For raw storage of the frame's data; don't ever touch this. cdef uint8_t *_buffer cdef size_t _buffer_size @@ -26,7 +25,7 @@ cdef class AudioFrame(Frame): :type: AudioFormat """ - cdef _init(self, lib.AVSampleFormat format, uint64_t layout, unsigned int nb_samples, unsigned int align) + cdef _init(self, lib.AVSampleFormat format, lib.AVChannelLayout layout, unsigned int nb_samples, unsigned int align) cdef _init_user_attributes(self) cdef AudioFrame alloc_audio_frame() diff --git a/av/audio/frame.py b/av/audio/frame.py new file mode 100644 index 000000000..214a947e3 --- /dev/null +++ b/av/audio/frame.py @@ -0,0 +1,206 @@ +import cython +from cython.cimports.av.audio.format import get_audio_format +from cython.cimports.av.audio.layout import get_audio_layout +from cython.cimports.av.audio.plane import AudioPlane +from cython.cimports.av.error import err_check +from cython.cimports.av.utils import check_ndarray + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def alloc_audio_frame() -> AudioFrame: + return AudioFrame(_cinit_bypass_sentinel) + + +format_dtypes = { + "dbl": "f8", + "dblp": "f8", + "flt": "f4", + "fltp": "f4", + "s16": "i2", + "s16p": "i2", + "s32": "i4", + "s32p": "i4", + "u8": "u1", + "u8p": "u1", +} + + +@cython.final +@cython.cclass +class AudioFrame(Frame): + """A frame of audio.""" + + def __cinit__(self, format="s16", layout="stereo", samples=0, align=1): + if format is _cinit_bypass_sentinel: + return + + cy_format: AudioFormat = AudioFormat(format) + cy_layout: AudioLayout = AudioLayout(layout) + self._init(cy_format.sample_fmt, cy_layout.layout, samples, align) + + @cython.cfunc + def _init( + self, + format: lib.AVSampleFormat, + layout: lib.AVChannelLayout, + nb_samples: cython.uint, + align: cython.uint, + ): + self.ptr.nb_samples = nb_samples + self.ptr.format = format + self.ptr.ch_layout = layout + + # Sometimes this is called twice. Oh well. + self._init_user_attributes() + + if self.layout.nb_channels != 0 and nb_samples: + # Cleanup the old buffer. + lib.av_freep(cython.address(self._buffer)) + + # Get a new one. + self._buffer_size = err_check( + lib.av_samples_get_buffer_size( + cython.NULL, self.layout.nb_channels, nb_samples, format, align + ) + ) + self._buffer = cython.cast( + cython.pointer[uint8_t], lib.av_malloc(self._buffer_size) + ) + if not self._buffer: + raise MemoryError("cannot allocate AudioFrame buffer") + + # Connect the data pointers to the buffer. + err_check( + lib.avcodec_fill_audio_frame( + self.ptr, + self.layout.nb_channels, + cython.cast(lib.AVSampleFormat, self.ptr.format), + self._buffer, + cython.cast(cython.int, self._buffer_size), + align, + ) + ) + + def __dealloc__(self): + lib.av_freep(cython.address(self._buffer)) + + @cython.cfunc + def _init_user_attributes(self): + self.layout = get_audio_layout(self.ptr.ch_layout) + self.format = get_audio_format(cython.cast(lib.AVSampleFormat, self.ptr.format)) + + def __repr__(self): + return ( + f"" + ) + + @staticmethod + def from_ndarray(array, format="s16", layout="stereo"): + """ + Construct a frame from a numpy array. + """ + import numpy as np + + py_format = format if isinstance(format, AudioFormat) else AudioFormat(format) + py_layout = layout if isinstance(layout, AudioLayout) else AudioLayout(layout) + format = py_format.name + + # map avcodec type to numpy type + try: + dtype = np.dtype(format_dtypes[format]) + except KeyError: + raise ValueError( + f"Conversion from numpy array with format `{format}` is not yet supported" + ) + + # check input format + nb_channels = py_layout.nb_channels + check_ndarray(array, dtype, 2) + if py_format.is_planar: + if array.shape[0] != nb_channels: + raise ValueError( + f"Expected planar `array.shape[0]` to equal `{nb_channels}` but got `{array.shape[0]}`" + ) + samples = array.shape[1] + else: + if array.shape[0] != 1: + raise ValueError( + f"Expected packed `array.shape[0]` to equal `1` but got `{array.shape[0]}`" + ) + samples = array.shape[1] // nb_channels + + frame = AudioFrame(format=py_format, layout=py_layout, samples=samples) + for i, plane in enumerate(frame.planes): + plane.update(array[i, :]) + return frame + + @property + def planes(self): + """ + A tuple of :class:`~av.audio.plane.AudioPlane`. + + :type: tuple + """ + plane_count: cython.int = 0 + while self.ptr.extended_data[plane_count]: + plane_count += 1 + + return tuple([AudioPlane(self, i) for i in range(plane_count)]) + + @property + def samples(self): + """ + Number of audio samples (per channel). + + :type: int + """ + return self.ptr.nb_samples + + @property + def sample_rate(self): + """ + Sample rate of the audio data, in samples per second. + + :type: int + """ + return self.ptr.sample_rate + + @sample_rate.setter + def sample_rate(self, value): + self.ptr.sample_rate = value + + @property + def rate(self): + """Another name for :attr:`sample_rate`.""" + return self.ptr.sample_rate + + @rate.setter + def rate(self, value): + self.ptr.sample_rate = value + + def to_ndarray(self): + """Get a numpy array of this frame. + + .. note:: Numpy must be installed. + + """ + import numpy as np + + try: + dtype = np.dtype(format_dtypes[self.format.name]) + except KeyError: + raise ValueError( + f"Conversion from {self.format.name!r} format to numpy array is not supported." + ) + + if self.format.is_planar: + count = self.samples + else: + count = self.samples * self.layout.nb_channels + + return np.vstack( + [np.frombuffer(x, dtype=dtype, count=count) for x in self.planes] + ) diff --git a/av/audio/frame.pyi b/av/audio/frame.pyi new file mode 100644 index 000000000..8efb26760 --- /dev/null +++ b/av/audio/frame.pyi @@ -0,0 +1,49 @@ +from typing import Any + +import numpy as np + +from av.frame import Frame + +from .format import AudioFormat +from .layout import AudioLayout +from .plane import AudioPlane + +format_dtypes: dict[str, str] +_SupportedNDarray = ( + np.ndarray[Any, np.dtype[np.float64]] # f8 + | np.ndarray[Any, np.dtype[np.float32]] # f4 + | np.ndarray[Any, np.dtype[np.int32]] # i4 + | np.ndarray[Any, np.dtype[np.int16]] # i2 + | np.ndarray[Any, np.dtype[np.uint8]] # u1 +) + +class _Format: + def __get__(self, i: object | None, owner: type | None = None) -> AudioFormat: ... + def __set__(self, instance: object, value: AudioFormat | str) -> None: ... + +class _Layout: + def __get__(self, i: object | None, owner: type | None = None) -> AudioLayout: ... + def __set__(self, instance: object, value: AudioLayout | str) -> None: ... + +class AudioFrame(Frame): + planes: tuple[AudioPlane, ...] + samples: int + sample_rate: int + rate: int + format: _Format + layout: _Layout + + def __init__( + self, + format: AudioFormat | str = "s16", + layout: AudioLayout | str = "stereo", + samples: int = 0, + align: int = 1, + ) -> None: ... + @staticmethod + def from_ndarray( + array: _SupportedNDarray, + format: AudioFormat | str = "s16", + layout: AudioLayout | str = "stereo", + ) -> AudioFrame: ... + def to_ndarray(self) -> _SupportedNDarray: ... diff --git a/av/audio/frame.pyx b/av/audio/frame.pyx deleted file mode 100644 index c9a7e6fef..000000000 --- a/av/audio/frame.pyx +++ /dev/null @@ -1,198 +0,0 @@ -from av.audio.format cimport get_audio_format -from av.audio.layout cimport get_audio_layout -from av.audio.plane cimport AudioPlane -from av.deprecation import renamed_attr -from av.error cimport err_check - - -cdef object _cinit_bypass_sentinel - - -format_dtypes = { - 'dbl': 'format - self.ptr.channel_layout = layout - - # Sometimes this is called twice. Oh well. - self._init_user_attributes() - - # Audio filters need AVFrame.channels to match number of channels from layout. - self.ptr.channels = self.layout.nb_channels - - cdef size_t buffer_size - if self.layout.channels and nb_samples: - - # Cleanup the old buffer. - lib.av_freep(&self._buffer) - - # Get a new one. - self._buffer_size = err_check(lib.av_samples_get_buffer_size( - NULL, - len(self.layout.channels), - nb_samples, - format, - align - )) - self._buffer = lib.av_malloc(self._buffer_size) - if not self._buffer: - raise MemoryError("cannot allocate AudioFrame buffer") - - # Connect the data pointers to the buffer. - err_check(lib.avcodec_fill_audio_frame( - self.ptr, - len(self.layout.channels), - self.ptr.format, - self._buffer, - self._buffer_size, - align - )) - - def __dealloc__(self): - lib.av_freep(&self._buffer) - - cdef _init_user_attributes(self): - self.layout = get_audio_layout(0, self.ptr.channel_layout) - self.format = get_audio_format(self.ptr.format) - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.index, - self.pts, - self.samples, - self.rate, - self.layout.name, - self.format.name, - id(self), - ) - - @staticmethod - def from_ndarray(array, format='s16', layout='stereo'): - """ - Construct a frame from a numpy array. - """ - import numpy as np - - # map avcodec type to numpy type - try: - dtype = np.dtype(format_dtypes[format]) - except KeyError: - raise ValueError('Conversion from numpy array with format `%s` is not yet supported' % format) - - nb_channels = len(AudioLayout(layout).channels) - assert array.dtype == dtype - assert array.ndim == 2 - if AudioFormat(format).is_planar: - assert array.shape[0] == nb_channels - samples = array.shape[1] - else: - assert array.shape[0] == 1 - samples = array.shape[1] // nb_channels - - frame = AudioFrame(format=format, layout=layout, samples=samples) - for i, plane in enumerate(frame.planes): - plane.update(array[i, :]) - return frame - - @property - def planes(self): - """ - A tuple of :class:`~av.audio.plane.AudioPlane`. - - :type: tuple - """ - cdef int plane_count = 0 - while self.ptr.extended_data[plane_count]: - plane_count += 1 - - return tuple([AudioPlane(self, i) for i in range(plane_count)]) - - property samples: - """ - Number of audio samples (per channel). - - :type: int - """ - def __get__(self): - return self.ptr.nb_samples - - property sample_rate: - """ - Sample rate of the audio data, in samples per second. - - :type: int - """ - def __get__(self): - return self.ptr.sample_rate - - def __set__(self, value): - self.ptr.sample_rate = value - - property rate: - """Another name for :attr:`sample_rate`.""" - def __get__(self): - return self.ptr.sample_rate - - def __set__(self, value): - self.ptr.sample_rate = value - - def to_ndarray(self, **kwargs): - """Get a numpy array of this frame. - - .. note:: Numpy must be installed. - - """ - import numpy as np - - # map avcodec type to numpy type - try: - dtype = np.dtype(format_dtypes[self.format.name]) - except KeyError: - raise ValueError("Conversion from {!r} format to numpy array is not supported.".format(self.format.name)) - - if self.format.is_planar: - count = self.samples - else: - count = self.samples * len(self.layout.channels) - - # convert and return data - return np.vstack([np.frombuffer(x, dtype=dtype, count=count) for x in self.planes]) - - to_nd_array = renamed_attr('to_ndarray') diff --git a/av/audio/layout.pxd b/av/audio/layout.pxd index 60c8c953d..d7e991e95 100644 --- a/av/audio/layout.pxd +++ b/av/audio/layout.pxd @@ -1,26 +1,7 @@ -from libc.stdint cimport uint64_t +cimport libav as lib -cdef class AudioLayout(object): +cdef class AudioLayout: + cdef lib.AVChannelLayout layout - # The layout for FFMpeg; this is essentially a bitmask of channels. - cdef uint64_t layout - cdef int nb_channels - - cdef readonly tuple channels - """ - A tuple of :class:`AudioChannel` objects. - - :type: tuple - """ - - cdef _init(self, uint64_t layout) - - -cdef class AudioChannel(object): - - # The channel for FFmpeg. - cdef uint64_t channel - - -cdef AudioLayout get_audio_layout(int channels, uint64_t c_layout) +cdef AudioLayout get_audio_layout(lib.AVChannelLayout c_layout) diff --git a/av/audio/layout.py b/av/audio/layout.py new file mode 100644 index 000000000..f3adb3514 --- /dev/null +++ b/av/audio/layout.py @@ -0,0 +1,109 @@ +from dataclasses import dataclass + +import cython +from cython.cimports import libav as lib +from cython.cimports.cpython.bytes import PyBytes_FromStringAndSize + + +@dataclass +class AudioChannel: + name: str + description: str + + def __repr__(self): + return f"" + + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def get_audio_layout(c_layout: lib.AVChannelLayout) -> AudioLayout: + """Get an AudioLayout from Cython land.""" + layout: AudioLayout = AudioLayout(_cinit_bypass_sentinel) + layout.layout = c_layout + return layout + + +@cython.final +@cython.cclass +class AudioLayout: + def __dealloc__(self): + lib.av_channel_layout_uninit(cython.address(self.layout)) + + def __cinit__(self, layout): + if layout is _cinit_bypass_sentinel: + return + + if type(layout) is str: + ret = lib.av_channel_layout_from_string(cython.address(c_layout), layout) + if ret != 0: + raise ValueError(f"Invalid layout: {layout}") + elif isinstance(layout, AudioLayout): + c_layout = cython.cast(AudioLayout, layout).layout + else: + raise TypeError( + f"layout must be of type: string | av.AudioLayout, got {type(layout)}" + ) + + self.layout = c_layout + + def __repr__(self): + return f"" + + def __eq__(self, other): + if not isinstance(other, AudioLayout): + return False + c_other: lib.AVChannelLayout = cython.cast(AudioLayout, other).layout + return ( + lib.av_channel_layout_compare( + cython.address(self.layout), cython.address(c_other) + ) + == 0 + ) + + @property + def nb_channels(self): + return self.layout.nb_channels + + @property + def channels(self): + buf: cython.char[16] + buf2: cython.char[128] + + results: list = [] + for index in range(self.layout.nb_channels): + size = lib.av_channel_name( + buf, + cython.sizeof(buf), + lib.av_channel_layout_channel_from_index( + cython.address(self.layout), index + ), + ) + size2 = lib.av_channel_description( + buf2, + cython.sizeof(buf2), + lib.av_channel_layout_channel_from_index( + cython.address(self.layout), index + ), + ) + results.append( + AudioChannel( + PyBytes_FromStringAndSize(buf, size - 1).decode("utf-8"), + PyBytes_FromStringAndSize(buf2, size2 - 1).decode("utf-8"), + ) + ) + + return tuple(results) + + @property + def name(self) -> str: + """The canonical name of the audio layout.""" + layout_name: cython.char[129] + ret: cython.int = lib.av_channel_layout_describe( + cython.address(self.layout), layout_name, cython.sizeof(layout_name) + ) + if ret < 0: + raise RuntimeError(f"Failed to get layout name: {ret}") + + return layout_name diff --git a/av/audio/layout.pyi b/av/audio/layout.pyi new file mode 100644 index 000000000..9fdf0ac15 --- /dev/null +++ b/av/audio/layout.pyi @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +class AudioLayout: + name: str + nb_channels: int + channels: tuple[AudioChannel, ...] + def __init__(self, layout: str | AudioLayout): ... + +@dataclass +class AudioChannel: + name: str + description: str diff --git a/av/audio/layout.pyx b/av/audio/layout.pyx deleted file mode 100644 index d4871553b..000000000 --- a/av/audio/layout.pyx +++ /dev/null @@ -1,124 +0,0 @@ -cimport libav as lib - - -cdef object _cinit_bypass_sentinel - -cdef AudioLayout get_audio_layout(int channels, uint64_t c_layout): - """Get an AudioLayout from Cython land.""" - cdef AudioLayout layout = AudioLayout.__new__(AudioLayout, _cinit_bypass_sentinel) - if channels and not c_layout: - c_layout = default_layouts[channels] - layout._init(c_layout) - return layout - - -# These are the defaults given by FFmpeg; Libav is different. -# TODO: What about av_get_default_channel_layout(...)? -cdef uint64_t default_layouts[17] -default_layouts[0] = 0 -default_layouts[1] = lib.AV_CH_LAYOUT_MONO -default_layouts[2] = lib.AV_CH_LAYOUT_STEREO -default_layouts[3] = lib.AV_CH_LAYOUT_2POINT1 -default_layouts[4] = lib.AV_CH_LAYOUT_4POINT0 -default_layouts[5] = lib.AV_CH_LAYOUT_5POINT0_BACK -default_layouts[6] = lib.AV_CH_LAYOUT_5POINT1_BACK -default_layouts[7] = lib.AV_CH_LAYOUT_6POINT1 -default_layouts[8] = lib.AV_CH_LAYOUT_7POINT1 -default_layouts[9] = 0x01FF -default_layouts[10] = 0x03FF -default_layouts[11] = 0x07FF -default_layouts[12] = 0x0FFF -default_layouts[13] = 0x1FFF -default_layouts[14] = 0x3FFF -default_layouts[15] = 0x7FFF -default_layouts[16] = 0xFFFF # FFmpeg has one here. - - -# These are the descriptions as given by FFmpeg; Libav does not have them. -cdef dict channel_descriptions = { - 'FL': 'front left', - 'FR': 'front right', - 'FC': 'front center', - 'LFE': 'low frequency', - 'BL': 'back left', - 'BR': 'back right', - 'FLC': 'front left-of-center', - 'FRC': 'front right-of-center', - 'BC': 'back center', - 'SL': 'side left', - 'SR': 'side right', - 'TC': 'top center', - 'TFL': 'top front left', - 'TFC': 'top front center', - 'TFR': 'top front right', - 'TBL': 'top back left', - 'TBC': 'top back center', - 'TBR': 'top back right', - 'DL': 'downmix left', - 'DR': 'downmix right', - 'WL': 'wide left', - 'WR': 'wide right', - 'SDL': 'surround direct left', - 'SDR': 'surround direct right', - 'LFE2': 'low frequency 2', -} - - -cdef class AudioLayout(object): - - def __init__(self, layout): - - if layout is _cinit_bypass_sentinel: - return - - cdef uint64_t c_layout - if isinstance(layout, int): - if layout < 0 or layout > 8: - raise ValueError('no layout with %d channels' % layout) - c_layout = default_layouts[layout] - elif isinstance(layout, str): - c_layout = lib.av_get_channel_layout(layout) - elif isinstance(layout, AudioLayout): - c_layout = layout.layout - else: - raise TypeError('layout must be str or int') - - if not c_layout: - raise ValueError('invalid channel layout %r' % layout) - - self._init(c_layout) - - cdef _init(self, uint64_t layout): - self.layout = layout - self.nb_channels = lib.av_get_channel_layout_nb_channels(layout) # This just counts bits. - self.channels = tuple(AudioChannel(self, i) for i in range(self.nb_channels)) - - def __repr__(self): - return '' % (self.__class__.__name__, self.name) - - property name: - """The canonical name of the audio layout.""" - def __get__(self): - cdef char out[32] - # Passing 0 as number of channels... fix this later? - lib.av_get_channel_layout_string(out, 32, 0, self.layout) - return out - - -cdef class AudioChannel(object): - - def __cinit__(self, AudioLayout layout, int index): - self.channel = lib.av_channel_layout_extract_channel(layout.layout, index) - - def __repr__(self): - return '' % (self.__class__.__name__, self.name, self.description) - - property name: - """The canonical name of the audio channel.""" - def __get__(self): - return lib.av_get_channel_name(self.channel) - - property description: - """A human description of the audio channel.""" - def __get__(self): - return channel_descriptions.get(self.name) diff --git a/av/audio/plane.pxd b/av/audio/plane.pxd index 316c84031..de912ac22 100644 --- a/av/audio/plane.pxd +++ b/av/audio/plane.pxd @@ -2,7 +2,5 @@ from av.plane cimport Plane cdef class AudioPlane(Plane): - cdef readonly size_t buffer_size - cdef size_t _buffer_size(self) diff --git a/av/audio/plane.py b/av/audio/plane.py new file mode 100644 index 000000000..e68b5403e --- /dev/null +++ b/av/audio/plane.py @@ -0,0 +1,14 @@ +import cython +from cython.cimports.av.audio.frame import AudioFrame + + +@cython.final +@cython.cclass +class AudioPlane(Plane): + def __cinit__(self, frame: AudioFrame, index: cython.int): + # Only the first linesize is ever populated, but it applies to every plane. + self.buffer_size = self.frame.ptr.linesize[0] + + @cython.cfunc + def _buffer_size(self) -> cython.size_t: + return self.buffer_size diff --git a/av/audio/plane.pyi b/av/audio/plane.pyi new file mode 100644 index 000000000..64524dcdb --- /dev/null +++ b/av/audio/plane.pyi @@ -0,0 +1,4 @@ +from av.plane import Plane + +class AudioPlane(Plane): + buffer_size: int diff --git a/av/audio/plane.pyx b/av/audio/plane.pyx deleted file mode 100644 index 50fe0aa59..000000000 --- a/av/audio/plane.pyx +++ /dev/null @@ -1,13 +0,0 @@ -cimport libav as lib - -from av.audio.frame cimport AudioFrame - - -cdef class AudioPlane(Plane): - - def __cinit__(self, AudioFrame frame, int index): - # Only the first linesize is ever populated, but it applies to every plane. - self.buffer_size = self.frame.ptr.linesize[0] - - cdef size_t _buffer_size(self): - return self.buffer_size diff --git a/av/audio/resampler.pxd b/av/audio/resampler.pxd index 4a2d9ceaf..2846bdfb6 100644 --- a/av/audio/resampler.pxd +++ b/av/audio/resampler.pxd @@ -1,31 +1,19 @@ -from libc.stdint cimport uint64_t -cimport libav as lib - from av.audio.format cimport AudioFormat from av.audio.frame cimport AudioFrame from av.audio.layout cimport AudioLayout +from av.filter.graph cimport Graph -cdef class AudioResampler(object): - +cdef class AudioResampler: cdef readonly bint is_passthrough - - cdef lib.SwrContext *ptr - cdef AudioFrame template - # Source descriptors; not for public consumption. - cdef unsigned int template_rate - # Destination descriptors cdef readonly AudioFormat format cdef readonly AudioLayout layout cdef readonly int rate + cdef readonly unsigned int frame_size + cdef readonly dict options - # Retiming. - cdef readonly uint64_t samples_in - cdef readonly double pts_per_sample_in - cdef readonly uint64_t samples_out - cdef readonly bint simple_pts_out - - cpdef resample(self, AudioFrame) + cdef Graph graph + cpdef list resample(self, AudioFrame) diff --git a/av/audio/resampler.py b/av/audio/resampler.py new file mode 100644 index 000000000..785e522b9 --- /dev/null +++ b/av/audio/resampler.py @@ -0,0 +1,140 @@ +from errno import EAGAIN + +import cython +from cython.cimports.av.filter.graph import Graph + +from av.error import FFmpegError + + +@cython.final +@cython.cclass +class AudioResampler: + """AudioResampler(format=None, layout=None, rate=None, frame_size=None, options=None) + + :param AudioFormat format: The target format, or string that parses to one + (e.g. ``"s16"``). + :param AudioLayout layout: The target layout, or an int/string that parses + to one (e.g. ``"stereo"``). + :param int rate: The target sample rate. + :param int frame_size: The number of samples per output frame. + :param dict options: ``libswresample`` options passed to the underlying + ``aresample`` filter (e.g. ``{"resampler": "soxr", "precision": "28"}``). + See the `FFmpeg resampler documentation + `_ for the full list. + """ + + def __cinit__( + self, format=None, layout=None, rate=None, frame_size=None, options=None + ): + if format is not None: + self.format = ( + format if isinstance(format, AudioFormat) else AudioFormat(format) + ) + + if layout is not None: + self.layout = AudioLayout(layout) + + self.rate = int(rate) if rate else 0 + self.frame_size = int(frame_size) if frame_size else 0 + self.options = {str(k): str(v) for k, v in options.items()} if options else {} + self.graph = None + + @cython.ccall + def resample(self, frame: AudioFrame | None) -> list: + """resample(frame) + + Convert the ``sample_rate``, ``channel_layout`` and/or ``format`` of + a :class:`~.AudioFrame`. + + :param AudioFrame frame: The frame to convert or `None` to flush. + :returns: A list of :class:`AudioFrame` in new parameters. If the nothing is to be done return the same frame + as a single element list. + + """ + # We don't have any input, so don't bother even setting up. + if not self.graph and frame is None: + return [] + + # Shortcut for passthrough. + if self.is_passthrough: + return [frame] + + # Take source settings from the first frame. + if not self.graph: + self.template = frame + + # Set some default descriptors. + self.format = self.format or frame.format + self.layout = self.layout or frame.layout + self.rate = self.rate or frame.sample_rate + + # Check if we can passthrough or if there is actually work to do. + if ( + frame.format.sample_fmt == self.format.sample_fmt + and frame.layout == self.layout + and frame.sample_rate == self.rate + and self.frame_size == 0 + ): + self.is_passthrough = True + return [frame] + + # handle resampling with aformat filter + # (similar to configure_output_audio_filter from ffmpeg) + self.graph = Graph() + extra_args = {} + if frame.time_base is not None: + extra_args["time_base"] = f"{frame.time_base}" + + abuffer = self.graph.add( + "abuffer", + sample_rate=f"{frame.sample_rate}", + sample_fmt=AudioFormat(frame.format).name, + channel_layout=frame.layout.name, + **extra_args, + ) + aformat = self.graph.add( + "aformat", + sample_rates=f"{self.rate}", + sample_fmts=self.format.name, + channel_layouts=self.layout.name, + ) + abuffersink = self.graph.add("abuffersink") + + # When libswresample options are given, do the conversion with an + # explicit aresample filter (which owns the SwrContext) instead of + # relying on the one FFmpeg auto-inserts before aformat. + if self.options: + aresample = self.graph.add("aresample", **self.options) + abuffer.link_to(aresample) + aresample.link_to(aformat) + else: + abuffer.link_to(aformat) + + aformat.link_to(abuffersink) + self.graph.configure() + + if self.frame_size > 0: + self.graph.set_audio_frame_size(self.frame_size) + + if frame is not None: + if ( + frame.format.sample_fmt != self.template.format.sample_fmt + or frame.layout != self.template.layout + or frame.sample_rate != self.template.rate + ): + raise ValueError("Frame does not match AudioResampler setup.") + + self.graph.push(frame) + + output: list = [] + while True: + try: + output.append(self.graph.pull()) + except EOFError: + break + except FFmpegError as e: + if e.errno != EAGAIN: + raise + break + + return output diff --git a/av/audio/resampler.pyi b/av/audio/resampler.pyi new file mode 100644 index 000000000..80c16cf08 --- /dev/null +++ b/av/audio/resampler.pyi @@ -0,0 +1,23 @@ +from av.filter.graph import Graph + +from .format import AudioFormat +from .frame import AudioFrame +from .layout import AudioLayout + +class AudioResampler: + rate: int + frame_size: int + format: AudioFormat + layout: AudioLayout + options: dict[str, str] + graph: Graph | None + + def __init__( + self, + format: str | int | AudioFormat | None = None, + layout: str | int | AudioLayout | None = None, + rate: int | None = None, + frame_size: int | None = None, + options: dict[str, str] | None = None, + ) -> None: ... + def resample(self, frame: AudioFrame | None) -> list[AudioFrame]: ... diff --git a/av/audio/resampler.pyx b/av/audio/resampler.pyx deleted file mode 100644 index bc24f4703..000000000 --- a/av/audio/resampler.pyx +++ /dev/null @@ -1,190 +0,0 @@ -from libc.stdint cimport int64_t, uint8_t -cimport libav as lib - -from av.audio.fifo cimport AudioFifo -from av.audio.format cimport get_audio_format -from av.audio.frame cimport alloc_audio_frame -from av.audio.layout cimport get_audio_layout -from av.error cimport err_check - -from av.error import FFmpegError - - -cdef class AudioResampler(object): - - """AudioResampler(format=None, layout=None, rate=None) - - :param AudioFormat format: The target format, or string that parses to one - (e.g. ``"s16"``). - :param AudioLayout layout: The target layout, or an int/string that parses - to one (e.g. ``"stereo"``). - :param int rate: The target sample rate. - - - """ - - def __cinit__(self, format=None, layout=None, rate=None): - if format is not None: - self.format = format if isinstance(format, AudioFormat) else AudioFormat(format) - if layout is not None: - self.layout = layout if isinstance(layout, AudioLayout) else AudioLayout(layout) - self.rate = int(rate) if rate else 0 - - def __dealloc__(self): - if self.ptr: - lib.swr_close(self.ptr) - lib.swr_free(&self.ptr) - - cpdef resample(self, AudioFrame frame): - """resample(frame) - - Convert the ``sample_rate``, ``channel_layout`` and/or ``format`` of - a :class:`~.AudioFrame`. - - :param AudioFrame frame: The frame to convert. - :returns: A new :class:`AudioFrame` in new parameters, or the same frame - if there is nothing to be done. - :raises: ``ValueError`` if ``Frame.pts`` is set and non-simple. - - """ - - if self.is_passthrough: - return frame - - # Take source settings from the first frame. - if not self.ptr: - - # We don't have any input, so don't bother even setting up. - if not frame: - return - - # Hold onto a copy of the attributes of the first frame to populate - # output frames with. - self.template = alloc_audio_frame() - self.template._copy_internal_attributes(frame) - self.template._init_user_attributes() - - # Set some default descriptors. - self.format = self.format or self.template.format - self.layout = self.layout or self.template.layout - self.rate = self.rate or self.template.ptr.sample_rate - - # Check if there is actually work to do. - if ( - self.template.format.sample_fmt == self.format.sample_fmt and - self.template.layout.layout == self.layout.layout and - self.template.ptr.sample_rate == self.rate - ): - self.is_passthrough = True - return frame - - # Figure out our time bases. - if frame._time_base.num and frame.ptr.sample_rate: - self.pts_per_sample_in = frame._time_base.den / float(frame._time_base.num) - self.pts_per_sample_in /= self.template.ptr.sample_rate - - # We will only provide outgoing PTS if the time_base is trivial. - if frame._time_base.num == 1 and frame._time_base.den == frame.ptr.sample_rate: - self.simple_pts_out = True - - self.ptr = lib.swr_alloc() - if not self.ptr: - raise RuntimeError('Could not allocate SwrContext.') - - # Configure it! - try: - err_check(lib.av_opt_set_int(self.ptr, 'in_sample_fmt', self.template.format.sample_fmt, 0)) - err_check(lib.av_opt_set_int(self.ptr, 'out_sample_fmt', self.format.sample_fmt, 0)) - err_check(lib.av_opt_set_int(self.ptr, 'in_channel_layout', self.template.layout.layout, 0)) - err_check(lib.av_opt_set_int(self.ptr, 'out_channel_layout', self.layout.layout, 0)) - err_check(lib.av_opt_set_int(self.ptr, 'in_sample_rate', self.template.ptr.sample_rate, 0)) - err_check(lib.av_opt_set_int(self.ptr, 'out_sample_rate', self.rate, 0)) - err_check(lib.swr_init(self.ptr)) - except FFmpegError: - self.ptr = NULL - raise - - elif frame: - - # Assert the settings are the same on consecutive frames. - if ( - frame.ptr.format != self.template.format.sample_fmt or - frame.ptr.channel_layout != self.template.layout.layout or - frame.ptr.sample_rate != self.template.ptr.sample_rate - ): - raise ValueError('Frame does not match AudioResampler setup.') - - # Assert that the PTS are what we expect. - cdef int64_t expected_pts - if frame is not None and frame.ptr.pts != lib.AV_NOPTS_VALUE: - expected_pts = (self.pts_per_sample_in * self.samples_in) - if frame.ptr.pts != expected_pts: - raise ValueError('Input frame pts %d != expected %d; fix or set to None.' % (frame.ptr.pts, expected_pts)) - self.samples_in += frame.ptr.nb_samples - - # The example "loop" as given in the FFmpeg documentation looks like: - # uint8_t **input; - # int in_samples; - # while (get_input(&input, &in_samples)) { - # uint8_t *output; - # int out_samples = av_rescale_rnd(swr_get_delay(swr, 48000) + - # in_samples, 44100, 48000, AV_ROUND_UP); - # av_samples_alloc(&output, NULL, 2, out_samples, - # AV_SAMPLE_FMT_S16, 0); - # out_samples = swr_convert(swr, &output, out_samples, - # input, in_samples); - # handle_output(output, out_samples); - # av_freep(&output); - # } - - # Estimate out how many samples this will create; it will be high. - # My investigations say that this swr_get_delay is not required, but - # it is in the example loop, and avresample (as opposed to swresample) - # may require it. - cdef int output_nb_samples = lib.av_rescale_rnd( - lib.swr_get_delay(self.ptr, self.rate) + frame.ptr.nb_samples, - self.rate, - self.template.ptr.sample_rate, - lib.AV_ROUND_UP, - ) if frame else lib.swr_get_delay(self.ptr, self.rate) - - # There aren't any frames coming, so no new frame pops out. - if not output_nb_samples: - return - - cdef AudioFrame output = alloc_audio_frame() - output._copy_internal_attributes(self.template) - output.ptr.sample_rate = self.rate - output._init( - self.format.sample_fmt, - self.layout.layout, - output_nb_samples, - 1, # Align? - ) - - output.ptr.nb_samples = err_check(lib.swr_convert( - self.ptr, - output.ptr.extended_data, - output_nb_samples, - # Cast for const-ness, because Cython isn't expressive enough. - (frame.ptr.extended_data if frame else NULL), - frame.ptr.nb_samples if frame else 0 - )) - - # Empty frame. - if output.ptr.nb_samples <= 0: - return - - # Create new PTSes in simple cases. - if self.simple_pts_out: - output._time_base.num = 1 - output._time_base.den = self.rate - output.ptr.pts = self.samples_out - else: - output._time_base.num = 0 - output._time_base.den = 1 - output.ptr.pts = lib.AV_NOPTS_VALUE - - self.samples_out += output.ptr.nb_samples - - return output diff --git a/av/audio/stream.pxd b/av/audio/stream.pxd index f8f68c263..8462061f8 100644 --- a/av/audio/stream.pxd +++ b/av/audio/stream.pxd @@ -1,5 +1,9 @@ +from av.packet cimport Packet from av.stream cimport Stream +from .frame cimport AudioFrame + cdef class AudioStream(Stream): - pass + cpdef encode(self, AudioFrame frame=?) + cpdef decode(self, Packet packet=?) diff --git a/av/audio/stream.py b/av/audio/stream.py new file mode 100644 index 000000000..e7fc63560 --- /dev/null +++ b/av/audio/stream.py @@ -0,0 +1,54 @@ +import cython +from cython.cimports import libav as lib +from cython.cimports.av.audio.frame import AudioFrame +from cython.cimports.av.packet import Packet + + +@cython.final +@cython.cclass +class AudioStream(Stream): + def __repr__(self): + if self.codec_context is None: + return f" at 0x{id(self):x}>" + form = self.format.name if self.format else None + return ( + f"" + ) + + def __getattr__(self, name): + if self.codec_context is None: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + return getattr(self.codec_context, name) + + @cython.ccall + def encode(self, frame: AudioFrame | None = None): + """ + Encode an :class:`.AudioFrame` and return a list of :class:`.Packet`. + + :rtype: list[Packet] + + .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. + """ + self._assert_has_codec_context(lib.AVERROR_ENCODER_NOT_FOUND) + packets = self.codec_context.encode(frame) + packet: Packet + for packet in packets: + packet._stream = self + packet.ptr.stream_index = self.ptr.index + + return packets + + @cython.ccall + def decode(self, packet: Packet | None = None): + """ + Decode a :class:`.Packet` and return a list of :class:`.AudioFrame`. + + :rtype: list[AudioFrame] + + .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. + """ + self._assert_has_codec_context() + return self.codec_context.decode(packet) diff --git a/av/audio/stream.pyi b/av/audio/stream.pyi new file mode 100644 index 000000000..f92fb52ba --- /dev/null +++ b/av/audio/stream.pyi @@ -0,0 +1,32 @@ +from typing import Literal + +from av.packet import Packet +from av.stream import Stream + +from .codeccontext import AudioCodecContext +from .format import AudioFormat +from .frame import AudioFrame +from .layout import AudioLayout + +class _Format: + def __get__(self, i: object | None, owner: type | None = None) -> AudioFormat: ... + def __set__(self, instance: object, value: AudioFormat | str) -> None: ... + +class _Layout: + def __get__(self, i: object | None, owner: type | None = None) -> AudioLayout: ... + def __set__(self, instance: object, value: AudioLayout | str) -> None: ... + +class AudioStream(Stream): + codec_context: AudioCodecContext + def encode(self, frame: AudioFrame | None = None) -> list[Packet]: ... + def decode(self, packet: Packet | None = None) -> list[AudioFrame]: ... + + # From codec context + frame_size: int + sample_rate: int + bit_rate: int + rate: int + channels: int + type: Literal["audio"] + format: _Format + layout: _Layout diff --git a/av/audio/stream.pyx b/av/audio/stream.pyx deleted file mode 100644 index a20356bb6..000000000 --- a/av/audio/stream.pyx +++ /dev/null @@ -1,13 +0,0 @@ - -cdef class AudioStream(Stream): - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.index, - self.name, - self.rate, - self.layout.name, - self.format.name, - id(self), - ) diff --git a/av/bitstream.pxd b/av/bitstream.pxd new file mode 100644 index 000000000..dbb89c984 --- /dev/null +++ b/av/bitstream.pxd @@ -0,0 +1,11 @@ +cimport libav as lib + +from av.packet cimport Packet + + +cdef class BitStreamFilterContext: + + cdef lib.AVBSFContext *ptr + + cpdef filter(self, Packet packet=?) + cpdef flush(self) diff --git a/av/bitstream.py b/av/bitstream.py new file mode 100644 index 000000000..a3bc0088b --- /dev/null +++ b/av/bitstream.py @@ -0,0 +1,132 @@ +import cython +import cython.cimports.libav as lib +from cython.cimports.av.codec.codec import Codec +from cython.cimports.av.error import err_check +from cython.cimports.av.packet import Packet +from cython.cimports.av.stream import Stream +from cython.cimports.libc.errno import EAGAIN + + +@cython.final +@cython.cclass +class BitStreamFilterContext: + """ + Initializes a bitstream filter: a way to directly modify packet data. + + Wraps :ffmpeg:`AVBSFContext` + + :param in_stream: Defines the input codec for the bitfilter. A :class:`.Stream` + copies the full input codec parameters, while a :class:`.Codec` or a codec-name + ``str`` only pins the input codec, which is all a codec-specific filter (such as + ``h264_mp4toannexb``) needs to initialize. + :type in_stream: :class:`.Stream`, :class:`.Codec`, str, or None + :param Stream out_stream: A stream whose codec is overwritten using the output parameters from the bitfilter. + """ + + def __cinit__( + self, + filter_description, + in_stream: Stream | Codec | str | None = None, + out_stream: Stream | None = None, + ): + res: cython.int + filter_str: cython.p_char = filter_description + + with cython.nogil: + res = lib.av_bsf_list_parse_str(filter_str, cython.address(self.ptr)) + err_check(res) + + if isinstance(in_stream, Stream): + with cython.nogil: + res = lib.avcodec_parameters_copy( + self.ptr.par_in, cython.cast(Stream, in_stream).ptr.codecpar + ) + err_check(res) + elif in_stream is not None: + # A Codec or codec name only pins the input codec, which is enough for + # codec-specific filters (e.g. h264_mp4toannexb) to initialize. + codec: Codec = ( + in_stream if isinstance(in_stream, Codec) else Codec(in_stream) + ) + self.ptr.par_in.codec_id = codec.ptr.id + self.ptr.par_in.codec_type = codec.ptr.type + + with cython.nogil: + res = lib.av_bsf_init(self.ptr) + err_check(res) + + if out_stream is not None: + with cython.nogil: + res = lib.avcodec_parameters_copy( + out_stream.ptr.codecpar, self.ptr.par_out + ) + err_check(res) + # codecpar carries everything the muxer needs; a mux-only stream + # (add_mux_stream) has no context to keep in sync. + if out_stream.codec_context is not None: + lib.avcodec_parameters_to_context( + out_stream.codec_context.ptr, out_stream.ptr.codecpar + ) + + def __dealloc__(self): + if self.ptr: + lib.av_bsf_free(cython.address(self.ptr)) + + @cython.ccall + def filter(self, packet: Packet | None = None): + """ + Processes a packet based on the filter_description set during initialization. + Multiple packets may be created. + + :type: list[Packet] + """ + res: cython.int + new_packet: Packet + + with cython.nogil: + res = lib.av_bsf_send_packet( + self.ptr, packet.ptr if packet is not None else cython.NULL + ) + err_check(res) + + output: list = [] + while True: + new_packet = Packet() + with cython.nogil: + res = lib.av_bsf_receive_packet(self.ptr, new_packet.ptr) + + if res == -EAGAIN or res == lib.AVERROR_EOF: + return output + + err_check(res) + if res: + return output + + output.append(new_packet) + + @cython.ccall + def flush(self): + """ + Reset the internal state of the filter. + Should be called e.g. when seeking. + Can be used to make the filter usable again after draining it with EOF marker packet. + """ + lib.av_bsf_flush(self.ptr) + + +@cython.cfunc +def get_filter_names() -> set: + names: set = set() + ptr: cython.pointer[cython.const[lib.AVBitStreamFilter]] + opaque: cython.p_void = cython.NULL + while True: + ptr = lib.av_bsf_iterate(cython.address(opaque)) + if ptr: + names.add(ptr.name) + else: + break + + return names + + +bitstream_filters_available = get_filter_names() diff --git a/av/bitstream.pyi b/av/bitstream.pyi new file mode 100644 index 000000000..34adc16bf --- /dev/null +++ b/av/bitstream.pyi @@ -0,0 +1,15 @@ +from .codec import Codec +from .packet import Packet +from .stream import Stream + +class BitStreamFilterContext: + def __init__( + self, + filter_description: str | bytes, + in_stream: Stream | Codec | str | None = None, + out_stream: Stream | None = None, + ): ... + def filter(self, packet: Packet | None) -> list[Packet]: ... + def flush(self) -> None: ... + +bitstream_filters_available: set[str] diff --git a/av/buffer.pxd b/av/buffer.pxd index 199d2cc8b..07ae34ae5 100644 --- a/av/buffer.pxd +++ b/av/buffer.pxd @@ -1,6 +1,16 @@ +from cpython.buffer cimport Py_buffer -cdef class Buffer(object): +cdef class ByteSource: + cdef object owner + cdef bint has_view + cdef Py_buffer view + cdef unsigned char *ptr + cdef size_t length + +cdef ByteSource bytesource(object, bint allow_none=*) + +cdef class Buffer: cdef size_t _buffer_size(self) cdef void* _buffer_ptr(self) cdef bint _buffer_writable(self) diff --git a/av/buffer.py b/av/buffer.py new file mode 100644 index 000000000..3fefceee4 --- /dev/null +++ b/av/buffer.py @@ -0,0 +1,100 @@ +import cython +from cython.cimports.cpython import PyBUF_WRITABLE, PyBuffer_FillInfo +from cython.cimports.cpython.buffer import ( + PyBUF_SIMPLE, + PyBuffer_Release, + PyObject_CheckBuffer, + PyObject_GetBuffer, +) +from cython.cimports.libc.string import memcpy + + +@cython.final +@cython.cclass +class ByteSource: + def __cinit__(self, owner): + self.owner = owner + + try: + self.ptr = owner + except TypeError: + pass + else: + self.length = len(owner) + return + + if PyObject_CheckBuffer(owner): + # Can very likely use PyBUF_ND instead of PyBUF_SIMPLE + res = PyObject_GetBuffer(owner, cython.address(self.view), PyBUF_SIMPLE) + if not res: + self.has_view = True + self.ptr = cython.cast(cython.p_uchar, self.view.buf) + self.length = self.view.len + return + + raise TypeError("expected bytes, bytearray or memoryview") + + def __dealloc__(self): + if self.has_view: + PyBuffer_Release(cython.address(self.view)) + + +@cython.cfunc +def bytesource(obj, allow_none: cython.bint = False) -> ByteSource | None: + if allow_none and obj is None: + return None + elif isinstance(obj, ByteSource): + return obj + else: + return ByteSource(obj) + + +@cython.cclass +class Buffer: + """A base class for PyAV objects which support the buffer protocol, such + as :class:`.Packet` and :class:`.Plane`. + + """ + + @cython.cfunc + def _buffer_size(self) -> cython.size_t: + return 0 + + def _buffer_ptr(self) -> cython.p_void: + return cython.NULL + + def _buffer_writable(self) -> cython.bint: + return True + + def __getbuffer__(self, view: cython.pointer[Py_buffer], flags: cython.int): + if flags & PyBUF_WRITABLE and not self._buffer_writable(): + raise ValueError("buffer is not writable") + + PyBuffer_FillInfo(view, self, self._buffer_ptr(), self._buffer_size(), 0, flags) + + @property + def buffer_size(self): + return self._buffer_size() + + @property + def buffer_ptr(self): + """The memory address of the buffer.""" + return cython.cast(cython.size_t, self._buffer_ptr()) + + def update(self, input): + """Replace the data in this object with the given buffer. + + Accepts anything that supports the `buffer protocol `_, + e.g. bytes, NumPy arrays, other :class:`Buffer` objects, etc.. + + """ + if not self._buffer_writable(): + raise ValueError("buffer is not writable") + + source: ByteSource = bytesource(input) + size: cython.size_t = self._buffer_size() + + if source.length != size: + raise ValueError(f"got {source.length} bytes; need {size} bytes") + + memcpy(self._buffer_ptr(), source.ptr, size) diff --git a/av/buffer.pyi b/av/buffer.pyi new file mode 100644 index 000000000..bc1090d1d --- /dev/null +++ b/av/buffer.pyi @@ -0,0 +1,9 @@ +# When Python 3.12 becomes our lowest supported version, we could make this +# class inherit `collections.abc.Buffer`. + +class Buffer: + buffer_size: int + buffer_ptr: int + def update(self, input: bytes) -> None: ... + def __buffer__(self, flags: int) -> memoryview: ... + def __bytes__(self) -> bytes: ... diff --git a/av/buffer.pyx b/av/buffer.pyx deleted file mode 100644 index 94f1c54d7..000000000 --- a/av/buffer.pyx +++ /dev/null @@ -1,64 +0,0 @@ -from cpython cimport PyBUF_WRITABLE, PyBuffer_FillInfo -from libc.string cimport memcpy - -from av.bytesource cimport ByteSource, bytesource - - -cdef class Buffer(object): - - """A base class for PyAV objects which support the buffer protocol, such - as :class:`.Packet` and :class:`.Plane`. - - """ - - cdef size_t _buffer_size(self): - return 0 - - cdef void* _buffer_ptr(self): - return NULL - - cdef bint _buffer_writable(self): - return True - - def __getbuffer__(self, Py_buffer *view, int flags): - if flags & PyBUF_WRITABLE and not self._buffer_writable(): - raise ValueError('buffer is not writable') - PyBuffer_FillInfo(view, self, self._buffer_ptr(), self._buffer_size(), 0, flags) - - @property - def buffer_size(self): - """The size of the buffer in bytes.""" - return self._buffer_size() - - @property - def buffer_ptr(self): - """The memory address of the buffer.""" - return self._buffer_ptr() - - def to_bytes(self): - """Return the contents of this buffer as ``bytes``. - - This copies the entire contents; consider using something that uses - the `buffer protocol `_ - as that will be more efficient. - - This is largely for Python2, as Python 3 can do the same via - ``bytes(the_buffer)``. - - """ - return (self._buffer_ptr())[:self._buffer_size()] - - def update(self, input): - """Replace the data in this object with the given buffer. - - Accepts anything that supports the `buffer protocol `_, - e.g. bytes, Numpy arrays, other :class:`Buffer` objects, etc.. - - """ - if not self._buffer_writable(): - raise ValueError('buffer is not writable') - cdef ByteSource source = bytesource(input) - cdef size_t size = self._buffer_size() - if source.length != size: - raise ValueError('got %d bytes; need %d bytes' % (source.length, size)) - memcpy(self._buffer_ptr(), source.ptr, size) diff --git a/av/bytesource.pxd b/av/bytesource.pxd deleted file mode 100644 index 68a6cca0f..000000000 --- a/av/bytesource.pxd +++ /dev/null @@ -1,14 +0,0 @@ -from cpython.buffer cimport Py_buffer - - -cdef class ByteSource(object): - - cdef object owner - - cdef bint has_view - cdef Py_buffer view - - cdef unsigned char *ptr - cdef size_t length - -cdef ByteSource bytesource(object, bint allow_none=*) diff --git a/av/bytesource.pyx b/av/bytesource.pyx deleted file mode 100644 index fd29bcf06..000000000 --- a/av/bytesource.pyx +++ /dev/null @@ -1,44 +0,0 @@ -from cpython.buffer cimport ( - PyBUF_SIMPLE, - PyBuffer_Release, - PyObject_CheckBuffer, - PyObject_GetBuffer -) - - -cdef class ByteSource(object): - - def __cinit__(self, owner): - self.owner = owner - - try: - self.ptr = owner - except TypeError: - pass - else: - self.length = len(owner) - return - - if PyObject_CheckBuffer(owner): - # Can very likely use PyBUF_ND instead of PyBUF_SIMPLE - res = PyObject_GetBuffer(owner, &self.view, PyBUF_SIMPLE) - if not res: - self.has_view = True - self.ptr = self.view.buf - self.length = self.view.len - return - - raise TypeError('expected bytes, bytearray or memoryview') - - def __dealloc__(self): - if self.has_view: - PyBuffer_Release(&self.view) - - -cdef ByteSource bytesource(obj, bint allow_none=False): - if allow_none and obj is None: - return - elif isinstance(obj, ByteSource): - return obj - else: - return ByteSource(obj) diff --git a/av/codec/__init__.pxd b/av/codec/__init__.pxd new file mode 100644 index 000000000..e69de29bb diff --git a/av/codec/__init__.py b/av/codec/__init__.py index c8c693a43..f8f589de8 100644 --- a/av/codec/__init__.py +++ b/av/codec/__init__.py @@ -1,8 +1,19 @@ from .codec import ( Capabilities, Codec, + PixFmtLoss, Properties, - codec_descriptor, - codecs_available + codecs_available, + find_best_pix_fmt_of_list, ) from .context import CodecContext + +__all__ = ( + "Capabilities", + "Codec", + "PixFmtLoss", + "Properties", + "codecs_available", + "find_best_pix_fmt_of_list", + "CodecContext", +) diff --git a/av/codec/codec.pxd b/av/codec/codec.pxd index 173f0ef18..576c659b4 100644 --- a/av/codec/codec.pxd +++ b/av/codec/codec.pxd @@ -1,12 +1,14 @@ cimport libav as lib -cdef class Codec(object): +cdef class Codec: cdef const lib.AVCodec *ptr cdef const lib.AVCodecDescriptor *desc cdef readonly bint is_encoder + cdef tuple _hardware_configs + cdef _init(self, name=?) diff --git a/av/codec/codec.py b/av/codec/codec.py new file mode 100644 index 000000000..0e8b958ec --- /dev/null +++ b/av/codec/codec.py @@ -0,0 +1,485 @@ +from enum import Flag, IntEnum, IntFlag + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.audio.format import get_audio_format +from cython.cimports.av.codec.hwaccel import wrap_hwconfig +from cython.cimports.av.rational import from_avrational +from cython.cimports.av.utils import avrational_to_fraction +from cython.cimports.av.video.format import VideoFormat, get_pix_fmt, get_video_format +from cython.cimports.libc.stdlib import free, malloc + +_cinit_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_codec(ptr: cython.pointer[cython.const[lib.AVCodec]]) -> Codec: + codec: Codec = Codec(_cinit_sentinel) + codec.ptr = ptr + codec.is_encoder = lib.av_codec_is_encoder(ptr) + codec._init() + return codec + + +class Properties(Flag): + NONE = 0 + INTRA_ONLY = lib.AV_CODEC_PROP_INTRA_ONLY + LOSSY = lib.AV_CODEC_PROP_LOSSY + LOSSLESS = lib.AV_CODEC_PROP_LOSSLESS + REORDER = lib.AV_CODEC_PROP_REORDER + BITMAP_SUB = lib.AV_CODEC_PROP_BITMAP_SUB + TEXT_SUB = lib.AV_CODEC_PROP_TEXT_SUB + + +class Capabilities(IntEnum): + none = 0 + draw_horiz_band = lib.AV_CODEC_CAP_DRAW_HORIZ_BAND + dr1 = lib.AV_CODEC_CAP_DR1 + hwaccel = 1 << 4 + delay = lib.AV_CODEC_CAP_DELAY + small_last_frame = lib.AV_CODEC_CAP_SMALL_LAST_FRAME + hwaccel_vdpau = 1 << 7 + experimental = lib.AV_CODEC_CAP_EXPERIMENTAL + channel_conf = lib.AV_CODEC_CAP_CHANNEL_CONF + neg_linesizes = 1 << 11 + frame_threads = lib.AV_CODEC_CAP_FRAME_THREADS + slice_threads = lib.AV_CODEC_CAP_SLICE_THREADS + param_change = lib.AV_CODEC_CAP_PARAM_CHANGE + auto_threads = lib.AV_CODEC_CAP_OTHER_THREADS + variable_frame_size = lib.AV_CODEC_CAP_VARIABLE_FRAME_SIZE + avoid_probing = lib.AV_CODEC_CAP_AVOID_PROBING + hardware = lib.AV_CODEC_CAP_HARDWARE + hybrid = lib.AV_CODEC_CAP_HYBRID + encoder_reordered_opaque = 1 << 20 + encoder_flush = 1 << 21 + encoder_recon_frame = 1 << 22 + + +class PixFmtLoss(IntFlag): + """Flags describing what is lost when converting between pixel formats. + + Returned by :func:`find_best_pix_fmt_of_list`. Mirrors FFmpeg's + ``FF_LOSS_*`` flags. + """ + + NONE = 0 + RESOLUTION = 0x0001 # loss due to resolution change + DEPTH = 0x0002 # loss due to color depth change + COLORSPACE = 0x0004 # loss due to color space conversion + ALPHA = 0x0008 # loss of alpha bit + COLORQUANT = 0x0010 # loss due to color quantization + CHROMA = 0x0020 # loss of chroma (e.g. RGB to gray conversion) + + +class UnknownCodecError(ValueError): + pass + + +@cython.final +@cython.cclass +class Codec: + """Codec(name, mode='r') + + :param str name: The codec name. + :param str mode: ``'r'`` for decoding or ``'w'`` for encoding. + + This object exposes information about an available codec, and an avenue to + create a :class:`.CodecContext` to encode/decode directly. + + :: + + >>> codec = Codec('mpeg4', 'r') + >>> codec.name + 'mpeg4' + >>> codec.type + 'video' + >>> codec.is_encoder + False + + """ + + def __cinit__(self, name, mode="r"): + if name is _cinit_sentinel: + return + + if mode == "w": + self.ptr = lib.avcodec_find_encoder_by_name(name) + if not self.ptr: + self.desc = lib.avcodec_descriptor_get_by_name(name) + if self.desc: + self.ptr = lib.avcodec_find_encoder(self.desc.id) + + elif mode == "r": + self.ptr = lib.avcodec_find_decoder_by_name(name) + if not self.ptr: + self.desc = lib.avcodec_descriptor_get_by_name(name) + if self.desc: + self.ptr = lib.avcodec_find_decoder(self.desc.id) + + else: + raise ValueError('Invalid mode; must be "r" or "w".', mode) + + self._init(name) + + # Sanity check. + if (mode == "w") != self.is_encoder: + raise RuntimeError("Found codec does not match mode.", name, mode) + + @cython.cfunc + def _init(self, name=None): + if not self.ptr: + raise UnknownCodecError(name) + + if not self.desc: + self.desc = lib.avcodec_descriptor_get(self.ptr.id) + if not self.desc: + raise RuntimeError(f"No codec descriptor for {name!r}.") + + self.is_encoder = lib.av_codec_is_encoder(self.ptr) + + # Sanity check. + if self.is_encoder and lib.av_codec_is_decoder(self.ptr): + raise RuntimeError("%s is both encoder and decoder.") + + def __repr__(self): + mode = self.mode + return f"" + + def create(self, kind=None): + """Create a :class:`.CodecContext` for this codec. + + :param str kind: Gives a hint to static type checkers for what exact CodecContext is used. + """ + from .context import CodecContext + + return CodecContext.create(self) + + @property + def mode(self): + return "w" if self.is_encoder else "r" + + @property + def is_decoder(self): + return not self.is_encoder + + @property + def name(self): + return self.ptr.name or "" + + @property + def canonical_name(self): + """ + Returns the name of the codec, not a specific encoder. + """ + return lib.avcodec_get_name(self.ptr.id) + + @property + def long_name(self): + return self.ptr.long_name or "" + + @property + def type(self): + """ + The media type of this codec. + + E.g: ``'audio'``, ``'video'``, ``'subtitle'``. + + """ + media_type = lib.av_get_media_type_string(self.ptr.type) + return "unknown" if media_type == cython.NULL else media_type + + @property + def id(self): + return self.ptr.id + + @property + def frame_rates(self): + """A list of supported frame rates (:class:`av.AVRational`), or ``None``.""" + out: cython.pointer[cython.const[cython.void]] = cython.NULL + num: cython.int = 0 + lib.avcodec_get_supported_config( + cython.NULL, + self.ptr, + lib.AV_CODEC_CONFIG_FRAME_RATE, + 0, + cython.address(out), + cython.address(num), + ) + if not out: + return + rates = cython.cast(cython.pointer[lib.AVRational], out) + return [from_avrational(rates[i]) for i in range(num)] + + @property + def audio_rates(self): + """A list of supported audio sample rates (``int``), or ``None``.""" + out: cython.pointer[cython.const[cython.void]] = cython.NULL + num: cython.int = 0 + lib.avcodec_get_supported_config( + cython.NULL, + self.ptr, + lib.AV_CODEC_CONFIG_SAMPLE_RATE, + 0, + cython.address(out), + cython.address(num), + ) + if not out: + return + rates = cython.cast(cython.pointer[cython.int], out) + return [rates[i] for i in range(num)] + + @property + def video_formats(self): + """A list of supported :class:`.VideoFormat`, or ``None``.""" + out: cython.pointer[cython.const[cython.void]] = cython.NULL + num: cython.int = 0 + lib.avcodec_get_supported_config( + cython.NULL, + self.ptr, + lib.AV_CODEC_CONFIG_PIX_FORMAT, + 0, + cython.address(out), + cython.address(num), + ) + if not out: + return + fmts = cython.cast(cython.pointer[lib.AVPixelFormat], out) + return [get_video_format(fmts[i], 0, 0) for i in range(num)] + + @property + def audio_formats(self): + """A list of supported :class:`.AudioFormat`, or ``None``.""" + out: cython.pointer[cython.const[cython.void]] = cython.NULL + num: cython.int = 0 + lib.avcodec_get_supported_config( + cython.NULL, + self.ptr, + lib.AV_CODEC_CONFIG_SAMPLE_FORMAT, + 0, + cython.address(out), + cython.address(num), + ) + if not out: + return + fmts = cython.cast(cython.pointer[lib.AVSampleFormat], out) + return [get_audio_format(fmts[i]) for i in range(num)] + + @property + def hardware_configs(self): + if self._hardware_configs: + return self._hardware_configs + ret: list = [] + i: cython.int = 0 + ptr: cython.pointer[cython.const[lib.AVCodecHWConfig]] + while True: + ptr = lib.avcodec_get_hw_config(self.ptr, i) + if not ptr: + break + ret.append(wrap_hwconfig(ptr)) + i += 1 + self._hardware_configs = tuple(ret) + return self._hardware_configs + + @property + def properties(self): + return self.desc.props + + @property + def intra_only(self): + return bool(self.desc.props & lib.AV_CODEC_PROP_INTRA_ONLY) + + @property + def lossy(self): + return bool(self.desc.props & lib.AV_CODEC_PROP_LOSSY) + + @property + def lossless(self): + return bool(self.desc.props & lib.AV_CODEC_PROP_LOSSLESS) + + @property + def reorder(self): + return bool(self.desc.props & lib.AV_CODEC_PROP_REORDER) + + @property + def bitmap_sub(self): + return bool(self.desc.props & lib.AV_CODEC_PROP_BITMAP_SUB) + + @property + def text_sub(self): + return bool(self.desc.props & lib.AV_CODEC_PROP_TEXT_SUB) + + @property + def capabilities(self): + """ + Get the capabilities bitmask of the codec. + + This method returns an integer representing the codec capabilities bitmask, + which can be used to check specific codec features by performing bitwise + operations with the Capabilities enum values. + + :example: + + .. code-block:: python + + from av.codec import Codec, Capabilities + + codec = Codec("h264", "w") + + # Check if the codec can be fed a final frame with a smaller size. + # This can be used to prevent truncation of the last audio samples. + small_last_frame = bool(codec.capabilities & Capabilities.small_last_frame) + + :rtype: int + """ + return self.ptr.capabilities + + @property + def experimental(self): + """ + Check if codec is experimental and is thus avoided in favor of non experimental encoders. + + :rtype: bool + """ + return bool(self.ptr.capabilities & lib.AV_CODEC_CAP_EXPERIMENTAL) + + @property + def delay(self): + """ + If true, encoder or decoder requires flushing with `None` at the end in order to give the complete and correct output. + + :rtype: bool + """ + return bool(self.ptr.capabilities & lib.AV_CODEC_CAP_DELAY) + + +@cython.cfunc +def get_codec_names(): + names: cython.set = set() + ptr = cython.declare(cython.pointer[cython.const[lib.AVCodec]]) + opaque: cython.p_void = cython.NULL + while True: + ptr = lib.av_codec_iterate(cython.address(opaque)) + if ptr: + names.add(ptr.name) + else: + break + return names + + +codecs_available = get_codec_names() + + +def dump_codecs(): + """Print information about available codecs.""" + + print( + """Codecs: + D..... = Decoding supported + .E.... = Encoding supported + ..V... = Video codec + ..A... = Audio codec + ..S... = Subtitle codec + ...I.. = Intra frame-only codec + ....L. = Lossy compression + .....S = Lossless compression + ------""" + ) + + for name in sorted(codecs_available): + try: + e_codec = Codec(name, "w") + except ValueError: + e_codec = None + + try: + d_codec = Codec(name, "r") + except ValueError: + d_codec = None + + # TODO: Assert these always have the same properties. + codec = e_codec or d_codec + + try: + print( + f" {'.D'[bool(d_codec)]}{'.E'[bool(e_codec)]}{codec.type[0].upper()}" + f"{'.I'[codec.intra_only]}{'.L'[codec.lossy]}{'.S'[codec.lossless]}" + f" {codec.name:<18} {codec.long_name}" + ) + except Exception as e: + print(f"...... {codec.name:<18} ERROR: {e}") + + +def dump_hwconfigs(): + print("Hardware configs:") + for name in sorted(codecs_available): + try: + codec = Codec(name, "r") + except ValueError: + continue + + configs = codec.hardware_configs + if not configs: + continue + + print(" ", codec.name) + for config in configs: + print(" ", config) + + +def find_best_pix_fmt_of_list(pix_fmts, src_pix_fmt, has_alpha=False): + """ + Find the best pixel format to convert to given a source format. + + Wraps :ffmpeg:`avcodec_find_best_pix_fmt_of_list`. + + :param pix_fmts: Iterable of pixel formats to choose from (str or VideoFormat). + :param src_pix_fmt: Source pixel format (str or VideoFormat). + :param bool has_alpha: Whether the source alpha channel is used. + :return: (best_format, loss) + :rtype: (VideoFormat | None, PixFmtLoss) + """ + src: lib.AVPixelFormat + best: lib.AVPixelFormat + c_list: cython.pointer[lib.AVPixelFormat] = cython.NULL + n: cython.Py_ssize_t + i: cython.Py_ssize_t + item: object + c_loss: cython.int + + if pix_fmts is None: + raise TypeError("pix_fmts must not be None") + + pix_fmts = tuple(pix_fmts) + if not pix_fmts: + return None, PixFmtLoss.NONE + + if isinstance(src_pix_fmt, VideoFormat): + src = cython.cast(VideoFormat, src_pix_fmt).pix_fmt + else: + src = get_pix_fmt(cython.cast(str, src_pix_fmt)) + + n = len(pix_fmts) + c_list = cython.cast( + cython.pointer[lib.AVPixelFormat], + malloc((n + 1) * cython.sizeof(lib.AVPixelFormat)), + ) + if c_list == cython.NULL: + raise MemoryError() + + try: + for i in range(n): + item = pix_fmts[i] + if isinstance(item, VideoFormat): + c_list[i] = cython.cast(VideoFormat, item).pix_fmt + else: + c_list[i] = get_pix_fmt(cython.cast(str, item)) + c_list[n] = lib.AV_PIX_FMT_NONE + + c_loss = 0 + best = lib.avcodec_find_best_pix_fmt_of_list( + c_list, src, 1 if has_alpha else 0, cython.address(c_loss) + ) + return get_video_format(best, 0, 0), PixFmtLoss(c_loss) + finally: + if c_list != cython.NULL: + free(c_list) diff --git a/av/codec/codec.pyi b/av/codec/codec.pyi new file mode 100644 index 000000000..be3953dd3 --- /dev/null +++ b/av/codec/codec.pyi @@ -0,0 +1,152 @@ +from collections.abc import Sequence +from enum import Flag, IntEnum, IntFlag +from typing import ClassVar, Literal, cast, overload + +from av.audio.codeccontext import AudioCodecContext +from av.audio.format import AudioFormat +from av.rational import AVRational +from av.subtitles.codeccontext import SubtitleCodecContext +from av.video.codeccontext import VideoCodecContext +from av.video.format import VideoFormat + +from .context import CodecContext +from .hwaccel import HWConfig + +class Properties(Flag): + NONE = cast(ClassVar[Properties], ...) + INTRA_ONLY = cast(ClassVar[Properties], ...) + LOSSY = cast(ClassVar[Properties], ...) + LOSSLESS = cast(ClassVar[Properties], ...) + REORDER = cast(ClassVar[Properties], ...) + BITMAP_SUB = cast(ClassVar[Properties], ...) + TEXT_SUB = cast(ClassVar[Properties], ...) + +class Capabilities(IntEnum): + none = cast(int, ...) + draw_horiz_band = cast(int, ...) + dr1 = cast(int, ...) + hwaccel = cast(int, ...) + delay = cast(int, ...) + small_last_frame = cast(int, ...) + hwaccel_vdpau = cast(int, ...) + subframes = cast(int, ...) + experimental = cast(int, ...) + channel_conf = cast(int, ...) + neg_linesizes = cast(int, ...) + frame_threads = cast(int, ...) + slice_threads = cast(int, ...) + param_change = cast(int, ...) + auto_threads = cast(int, ...) + variable_frame_size = cast(int, ...) + avoid_probing = cast(int, ...) + hardware = cast(int, ...) + hybrid = cast(int, ...) + encoder_reordered_opaque = cast(int, ...) + encoder_flush = cast(int, ...) + encoder_recon_frame = cast(int, ...) + +class PixFmtLoss(IntFlag): + NONE = cast(ClassVar[PixFmtLoss], ...) + RESOLUTION = cast(ClassVar[PixFmtLoss], ...) + DEPTH = cast(ClassVar[PixFmtLoss], ...) + COLORSPACE = cast(ClassVar[PixFmtLoss], ...) + ALPHA = cast(ClassVar[PixFmtLoss], ...) + COLORQUANT = cast(ClassVar[PixFmtLoss], ...) + CHROMA = cast(ClassVar[PixFmtLoss], ...) + +class UnknownCodecError(ValueError): ... + +class Codec: + @property + def is_encoder(self) -> bool: ... + @property + def is_decoder(self) -> bool: ... + @property + def mode(self) -> Literal["r", "w"]: ... + @property + def name(self) -> str: ... + @property + def canonical_name(self) -> str: ... + @property + def long_name(self) -> str: ... + @property + def type( + self, + ) -> Literal["video", "audio", "data", "subtitle", "attachment", "unknown"]: ... + @property + def id(self) -> int: ... + frame_rates: list[AVRational] | None + audio_rates: list[int] | None + video_formats: list[VideoFormat] | None + audio_formats: list[AudioFormat] | None + hardware_configs: list[HWConfig] + + @property + def properties(self) -> int: ... + @property + def intra_only(self) -> bool: ... + @property + def lossy(self) -> bool: ... + @property + def lossless(self) -> bool: ... + @property + def reorder(self) -> bool: ... + @property + def bitmap_sub(self) -> bool: ... + @property + def text_sub(self) -> bool: ... + @property + def capabilities(self) -> int: ... + @property + def experimental(self) -> bool: ... + @property + def delay(self) -> bool: ... + def __init__(self, name: str, mode: Literal["r", "w"] = "r") -> None: ... + @overload + def create(self, kind: Literal["video"]) -> VideoCodecContext: ... + @overload + def create(self, kind: Literal["audio"]) -> AudioCodecContext: ... + @overload + def create(self, kind: Literal["subtitle"]) -> SubtitleCodecContext: ... + @overload + def create(self, kind: None = None) -> CodecContext: ... + @overload + def create( + self, kind: Literal["video", "audio", "subtitle"] | None = None + ) -> ( + VideoCodecContext | AudioCodecContext | SubtitleCodecContext | CodecContext + ): ... + +codecs_available: set[str] + +def dump_codecs() -> None: ... +def dump_hwconfigs() -> None: ... + +PixFmtLike = str | VideoFormat + +def find_best_pix_fmt_of_list( + pix_fmts: Sequence[PixFmtLike], + src_pix_fmt: PixFmtLike, + has_alpha: bool = False, +) -> tuple[VideoFormat | None, PixFmtLoss]: + """ + Find the best pixel format to convert to given a source format. + + Wraps :ffmpeg:`avcodec_find_best_pix_fmt_of_list`. + + :param pix_fmts: Iterable of pixel formats to choose from (str or VideoFormat). + :param src_pix_fmt: Source pixel format (str or VideoFormat). + :param bool has_alpha: Whether the source alpha channel is used. + :return: (best_format, loss): best_format is the best matching pixel format from + the list, or None if no suitable format was found; loss is a combination of + :class:`PixFmtLoss` flags informing you what kind of losses will occur. + :rtype: (VideoFormat | None, PixFmtLoss) + + Note on loss: it is an :class:`enum.IntFlag` describing what kinds of information + would be lost converting from src_pix_fmt to best_format (e.g. loss of alpha, + chroma, colorspace, resolution, bit depth, etc.). Multiple losses can be present + at once, so the value can be tested with bitwise & against the :class:`PixFmtLoss` + members. + For exact behavior see: libavutil/pixdesc.c/get_pix_fmt_score() in ffmpeg source code. + """ + ... diff --git a/av/codec/codec.pyx b/av/codec/codec.pyx deleted file mode 100644 index 4bbbcf369..000000000 --- a/av/codec/codec.pyx +++ /dev/null @@ -1,392 +0,0 @@ -from av.audio.format cimport get_audio_format -from av.descriptor cimport wrap_avclass -from av.enum cimport define_enum -from av.utils cimport avrational_to_fraction, flag_in_bitfield -from av.video.format cimport get_video_format - - -cdef object _cinit_sentinel = object() - - -cdef Codec wrap_codec(const lib.AVCodec *ptr): - cdef Codec codec = Codec(_cinit_sentinel) - codec.ptr = ptr - codec.is_encoder = lib.av_codec_is_encoder(ptr) - codec._init() - return codec - - -Properties = define_enum('Properties', 'av.codec', ( - ('NONE', 0), - ('INTRA_ONLY', lib.AV_CODEC_PROP_INTRA_ONLY, - """Codec uses only intra compression. - Video and audio codecs only."""), - ('LOSSY', lib.AV_CODEC_PROP_LOSSY, - """Codec supports lossy compression. Audio and video codecs only. - - Note: A codec may support both lossy and lossless - compression modes."""), - ('LOSSLESS', lib.AV_CODEC_PROP_LOSSLESS, - """Codec supports lossless compression. Audio and video codecs only."""), - ('REORDER', lib.AV_CODEC_PROP_REORDER, - """Codec supports frame reordering. That is, the coded order (the order in which - the encoded packets are output by the encoders / stored / input to the - decoders) may be different from the presentation order of the corresponding - frames. - - For codecs that do not have this property set, PTS and DTS should always be - equal."""), - ('BITMAP_SUB', lib.AV_CODEC_PROP_BITMAP_SUB, - """Subtitle codec is bitmap based - Decoded AVSubtitle data can be read from the AVSubtitleRect->pict field."""), - ('TEXT_SUB', lib.AV_CODEC_PROP_TEXT_SUB, - """Subtitle codec is text based. - Decoded AVSubtitle data can be read from the AVSubtitleRect->ass field."""), -), is_flags=True) - -Capabilities = define_enum('Capabilities', 'av.codec', ( - ('NONE', 0), - ('DRAW_HORIZ_BAND', lib.AV_CODEC_CAP_DRAW_HORIZ_BAND, - """Decoder can use draw_horiz_band callback."""), - ('DR1', lib.AV_CODEC_CAP_DR1, - """Codec uses get_buffer() for allocating buffers and supports custom allocators. - If not set, it might not use get_buffer() at all or use operations that - assume the buffer was allocated by avcodec_default_get_buffer."""), - ('TRUNCATED', lib.AV_CODEC_CAP_TRUNCATED), - ('HWACCEL', 1 << 4), - ('DELAY', lib.AV_CODEC_CAP_DELAY, - """Encoder or decoder requires flushing with NULL input at the end in order to - give the complete and correct output. - - NOTE: If this flag is not set, the codec is guaranteed to never be fed with - with NULL data. The user can still send NULL data to the public encode - or decode function, but libavcodec will not pass it along to the codec - unless this flag is set. - - Decoders: - The decoder has a non-zero delay and needs to be fed with avpkt->data=NULL, - avpkt->size=0 at the end to get the delayed data until the decoder no longer - returns frames. - - Encoders: - The encoder needs to be fed with NULL data at the end of encoding until the - encoder no longer returns data. - - NOTE: For encoders implementing the AVCodec.encode2() function, setting this - flag also means that the encoder must set the pts and duration for - each output packet. If this flag is not set, the pts and duration will - be determined by libavcodec from the input frame."""), - ('SMALL_LAST_FRAME', lib.AV_CODEC_CAP_SMALL_LAST_FRAME, - """Codec can be fed a final frame with a smaller size. - This can be used to prevent truncation of the last audio samples."""), - ('HWACCEL_VDPAU', 1 << 7), - ('SUBFRAMES', lib.AV_CODEC_CAP_SUBFRAMES, - """Codec can output multiple frames per AVPacket - Normally demuxers return one frame at a time, demuxers which do not do - are connected to a parser to split what they return into proper frames. - This flag is reserved to the very rare category of codecs which have a - bitstream that cannot be split into frames without timeconsuming - operations like full decoding. Demuxers carrying such bitstreams thus - may return multiple frames in a packet. This has many disadvantages like - prohibiting stream copy in many cases thus it should only be considered - as a last resort."""), - ('EXPERIMENTAL', lib.AV_CODEC_CAP_EXPERIMENTAL, - """Codec is experimental and is thus avoided in favor of non experimental - encoders"""), - ('CHANNEL_CONF', lib.AV_CODEC_CAP_CHANNEL_CONF, - """Codec should fill in channel configuration and samplerate instead of container"""), - ('NEG_LINESIZES', 1 << 11), - ('FRAME_THREADS', lib.AV_CODEC_CAP_FRAME_THREADS, - """Codec supports frame-level multithreading""",), - ('SLICE_THREADS', lib.AV_CODEC_CAP_SLICE_THREADS, - """Codec supports slice-based (or partition-based) multithreading."""), - ('PARAM_CHANGE', lib.AV_CODEC_CAP_PARAM_CHANGE, - """Codec supports changed parameters at any point."""), - ('AUTO_THREADS', lib.AV_CODEC_CAP_AUTO_THREADS, - """Codec supports avctx->thread_count == 0 (auto)."""), - ('VARIABLE_FRAME_SIZE', lib.AV_CODEC_CAP_VARIABLE_FRAME_SIZE, - """Audio encoder supports receiving a different number of samples in each call."""), - ('AVOID_PROBING', lib.AV_CODEC_CAP_AVOID_PROBING, - """Decoder is not a preferred choice for probing. - This indicates that the decoder is not a good choice for probing. - It could for example be an expensive to spin up hardware decoder, - or it could simply not provide a lot of useful information about - the stream. - A decoder marked with this flag should only be used as last resort - choice for probing."""), - ('INTRA_ONLY', lib.AV_CODEC_CAP_INTRA_ONLY, - """Codec is intra only."""), - ('LOSSLESS', lib.AV_CODEC_CAP_LOSSLESS, - """Codec is lossless."""), - ('HARDWARE', lib.AV_CODEC_CAP_HARDWARE, - """Codec is backed by a hardware implementation. Typically used to - identify a non-hwaccel hardware decoder. For information about hwaccels, use - avcodec_get_hw_config() instead."""), - ('HYBRID', lib.AV_CODEC_CAP_HYBRID, - """Codec is potentially backed by a hardware implementation, but not - necessarily. This is used instead of AV_CODEC_CAP_HARDWARE, if the - implementation provides some sort of internal fallback."""), - ('ENCODER_REORDERED_OPAQUE', 1 << 20, # lib.AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE, # FFmpeg 4.2 - """This codec takes the reordered_opaque field from input AVFrames - and returns it in the corresponding field in AVCodecContext after - encoding."""), -), is_flags=True) - - -class UnknownCodecError(ValueError): - pass - - -cdef class Codec(object): - - """Codec(name, mode='r') - - :param str name: The codec name. - :param str mode: ``'r'`` for decoding or ``'w'`` for encoding. - - This object exposes information about an available codec, and an avenue to - create a :class:`.CodecContext` to encode/decode directly. - - :: - - >>> codec = Codec('mpeg4', 'r') - >>> codec.name - 'mpeg4' - >>> codec.type - 'video' - >>> codec.is_encoder - False - - """ - - def __cinit__(self, name, mode='r'): - - if name is _cinit_sentinel: - return - - if mode == 'w': - self.ptr = lib.avcodec_find_encoder_by_name(name) - if not self.ptr: - self.desc = lib.avcodec_descriptor_get_by_name(name) - if self.desc: - self.ptr = lib.avcodec_find_encoder(self.desc.id) - - elif mode == 'r': - self.ptr = lib.avcodec_find_decoder_by_name(name) - if not self.ptr: - self.desc = lib.avcodec_descriptor_get_by_name(name) - if self.desc: - self.ptr = lib.avcodec_find_decoder(self.desc.id) - - else: - raise ValueError('Invalid mode; must be "r" or "w".', mode) - - self._init(name) - - # Sanity check. - if (mode == 'w') != self.is_encoder: - raise RuntimeError("Found codec does not match mode.", name, mode) - - cdef _init(self, name=None): - - if not self.ptr: - raise UnknownCodecError(name) - - if not self.desc: - self.desc = lib.avcodec_descriptor_get(self.ptr.id) - if not self.desc: - raise RuntimeError('No codec descriptor for %r.' % name) - - self.is_encoder = lib.av_codec_is_encoder(self.ptr) - - # Sanity check. - if self.is_encoder and lib.av_codec_is_decoder(self.ptr): - raise RuntimeError('%s is both encoder and decoder.') - - def create(self): - """Create a :class:`.CodecContext` for this codec.""" - from .context import CodecContext - return CodecContext.create(self) - - property is_decoder: - def __get__(self): - return not self.is_encoder - - property descriptor: - def __get__(self): return wrap_avclass(self.ptr.priv_class) - - property name: - def __get__(self): return self.ptr.name or '' - property long_name: - def __get__(self): return self.ptr.long_name or '' - - @property - def type(self): - """ - The media type of this codec. - - E.g: ``'audio'``, ``'video'``, ``'subtitle'``. - - """ - return lib.av_get_media_type_string(self.ptr.type) - - property id: - def __get__(self): return self.ptr.id - - @property - def frame_rates(self): - """A list of supported frame rates (:class:`fractions.Fraction`), or ``None``.""" - if not self.ptr.supported_framerates: - return - - ret = [] - cdef int i = 0 - while self.ptr.supported_framerates[i].denum: - ret.append(avrational_to_fraction(&self.ptr.supported_framerates[i])) - i += 1 - return ret - - @property - def audio_rates(self): - """A list of supported audio sample rates (``int``), or ``None``.""" - if not self.ptr.supported_samplerates: - return - - ret = [] - cdef int i = 0 - while self.ptr.supported_samplerates[i]: - ret.append(self.ptr.supported_samplerates[i]) - i += 1 - return ret - - @property - def video_formats(self): - """A list of supported :class:`.VideoFormat`, or ``None``.""" - if not self.ptr.pix_fmts: - return - - ret = [] - cdef int i = 0 - while self.ptr.pix_fmts[i] != -1: - ret.append(get_video_format(self.ptr.pix_fmts[i], 0, 0)) - i += 1 - return ret - - @property - def audio_formats(self): - """A list of supported :class:`.AudioFormat`, or ``None``.""" - if not self.ptr.sample_fmts: - return - - ret = [] - cdef int i = 0 - while self.ptr.sample_fmts[i] != -1: - ret.append(get_audio_format(self.ptr.sample_fmts[i])) - i += 1 - return ret - - # NOTE: there are some overlaps, which we defer to how `ffmpeg -codecs` - # handles them (by prefering the capablity to the property). - # Also, LOSSLESS and LOSSY don't have to agree. - - @Properties.property - def properties(self): - """Flag property of :class:`.Properties`""" - return self.desc.props - - intra_only = properties.flag_property('INTRA_ONLY') - lossy = properties.flag_property('LOSSY') # Defer to capability. - lossless = properties.flag_property('LOSSLESS') # Defer to capability. - reorder = properties.flag_property('REORDER') - bitmap_sub = properties.flag_property('BITMAP_SUB') - text_sub = properties.flag_property('TEXT_SUB') - - @Capabilities.property - def capabilities(self): - """Flag property of :class:`.Capabilities`""" - return self.ptr.capabilities - - draw_horiz_band = capabilities.flag_property('DRAW_HORIZ_BAND') - dr1 = capabilities.flag_property('DR1') - truncated = capabilities.flag_property('TRUNCATED') - hwaccel = capabilities.flag_property('HWACCEL') - delay = capabilities.flag_property('DELAY') - small_last_frame = capabilities.flag_property('SMALL_LAST_FRAME') - hwaccel_vdpau = capabilities.flag_property('HWACCEL_VDPAU') - subframes = capabilities.flag_property('SUBFRAMES') - experimental = capabilities.flag_property('EXPERIMENTAL') - channel_conf = capabilities.flag_property('CHANNEL_CONF') - neg_linesizes = capabilities.flag_property('NEG_LINESIZES') - frame_threads = capabilities.flag_property('FRAME_THREADS') - slice_threads = capabilities.flag_property('SLICE_THREADS') - param_change = capabilities.flag_property('PARAM_CHANGE') - auto_threads = capabilities.flag_property('AUTO_THREADS') - variable_frame_size = capabilities.flag_property('VARIABLE_FRAME_SIZE') - avoid_probing = capabilities.flag_property('AVOID_PROBING') - # intra_only = capabilities.flag_property('INTRA_ONLY') # Dupes. - # lossless = capabilities.flag_property('LOSSLESS') # Dupes. - hardware = capabilities.flag_property('HARDWARE') - hybrid = capabilities.flag_property('HYBRID') - encoder_reordered_opaque = capabilities.flag_property('ENCODER_REORDERED_OPAQUE') - - -cdef get_codec_names(): - names = set() - cdef const lib.AVCodec *ptr - cdef void *opaque = NULL - while True: - ptr = lib.av_codec_iterate(&opaque) - if ptr: - names.add(ptr.name) - else: - break - return names - -codecs_available = get_codec_names() - - -codec_descriptor = wrap_avclass(lib.avcodec_get_class()) - - -def dump_codecs(): - """Print information about available codecs.""" - - print '''Codecs: - D..... = Decoding supported - .E.... = Encoding supported - ..V... = Video codec - ..A... = Audio codec - ..S... = Subtitle codec - ...I.. = Intra frame-only codec - ....L. = Lossy compression - .....S = Lossless compression - ------''' - - for name in sorted(codecs_available): - - try: - e_codec = Codec(name, 'w') - except ValueError: - e_codec = None - - try: - d_codec = Codec(name, 'r') - except ValueError: - d_codec = None - - # TODO: Assert these always have the same properties. - codec = e_codec or d_codec - - try: - print ' %s%s%s%s%s%s %-18s %s' % ( - '.D'[bool(d_codec)], - '.E'[bool(e_codec)], - codec.type[0].upper(), - '.I'[codec.intra_only], - '.L'[codec.lossy], - '.S'[codec.lossless], - codec.name, - codec.long_name - ) - except Exception as e: - print '...... %-18s ERROR: %s' % (codec.name, e) diff --git a/av/codec/context.pxd b/av/codec/context.pxd index d9b6906f9..a9cb702a7 100644 --- a/av/codec/context.pxd +++ b/av/codec/context.pxd @@ -1,67 +1,67 @@ -from libc.stdint cimport int64_t cimport libav as lib +from libc.stdint cimport int64_t, uint8_t -from av.bytesource cimport ByteSource +from av.buffer cimport ByteSource from av.codec.codec cimport Codec +from av.codec.hwaccel cimport HWAccel from av.frame cimport Frame from av.packet cimport Packet -cdef class CodecContext(object): - +cdef class CodecContext: cdef lib.AVCodecContext *ptr - # Whether the AVCodecContext should be de-allocated upon destruction. - cdef bint allocated - - # Whether AVCodecContext.extradata should be de-allocated upon destruction. - cdef bint extradata_set - - # Used as a signal that this is within a stream, and also for us to access - # that stream. This is set "manually" by the stream after constructing - # this object. + # Used as a signal that this is within a stream, and also for us to access that + # stream. This is set "manually" by the stream after constructing this object. cdef int stream_index cdef lib.AVCodecParserContext *parser + cdef _init(self, lib.AVCodecContext *ptr, const lib.AVCodec *codec, HWAccel hwaccel) + cdef _assert_not_open(self, name) - cdef _init(self, lib.AVCodecContext *ptr, const lib.AVCodec *codec) - + # Public API. + cdef readonly bint is_open cdef readonly Codec codec - + cdef readonly HWAccel hwaccel cdef public dict options - - # Public API. cpdef open(self, bint strict=?) - cpdef close(self, bint strict=?) - - cdef _set_default_time_base(self) # Wraps both versions of the transcode API, returning lists. cpdef encode(self, Frame frame=?) cpdef decode(self, Packet packet=?) + cdef _decode(self, Packet packet) + cpdef flush_buffers(self) + + # Used by hardware-accelerated decode. + cdef HWAccel hwaccel_ctx + + cdef uint8_t _ctxflags # ctxEnum: template_initialized + # True when created via add_stream_from_template(); start_encoding() skips + # avcodec_open2() and lets encode()/decode() open the codec lazily if needed. # Used by both transcode APIs to setup user-land objects. - # TODO: Remove the `Packet` from `_setup_decoded_frame` (because flushing - # packets are bogus). It should take all info it needs from the context and/or stream. - cdef _prepare_frames_for_encode(self, Frame frame) + # TODO: Remove the `Packet` from `_setup_decoded_frame` (because flushing packets + # are bogus). It should take all info it needs from the context and/or stream. + cdef _prepare_and_time_rebase_frames_for_encode(self, Frame frame) + cdef void _setup_encode_hwframes(self) + cdef list _prepare_frames_for_encode(self, Frame frame) cdef _setup_encoded_packet(self, Packet) cdef _setup_decoded_frame(self, Frame, Packet) # Implemented by base for the generic send/recv API. - # Note that the user cannot send without recieving. This is because - # _prepare_frames_for_encode may expand a frame into multiple (e.g. when + # Note that the user cannot send without receiving. This is because + # `_prepare_frames_for_encode` may expand a frame into multiple (e.g. when # resampling audio to a higher rate but with fixed size frames), and the # send/recv buffer may be limited to a single frame. Ergo, we need to flush # the buffer as often as possible. - cdef _send_frame_and_recv(self, Frame frame) cdef _recv_packet(self) - cdef _send_packet_and_recv(self, Packet packet) cdef _recv_frame(self) + cdef _transfer_hwframe(self, Frame frame) + # Implemented by children for the generic send/recv API, so we have the # correct subclass of Frame. cdef Frame _next_frame cdef Frame _alloc_next_frame(self) - -cdef CodecContext wrap_codec_context(lib.AVCodecContext*, const lib.AVCodec*, bint allocated) +cdef CodecContext wrap_codec_context(lib.AVCodecContext*, const lib.AVCodec*, HWAccel hwaccel) diff --git a/av/codec/context.py b/av/codec/context.py new file mode 100644 index 000000000..7b3fb7cca --- /dev/null +++ b/av/codec/context.py @@ -0,0 +1,1007 @@ +from dataclasses import dataclass +from enum import Flag, IntEnum, IntFlag + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.buffer import ByteSource, bytesource +from cython.cimports.av.codec.codec import Codec, wrap_codec +from cython.cimports.av.dictionary import Dictionary +from cython.cimports.av.error import err_check +from cython.cimports.av.packet import Packet +from cython.cimports.av.utils import avrational_to_fraction, to_avrational +from cython.cimports.libc.errno import EAGAIN +from cython.cimports.libc.stdint import uint8_t +from cython.cimports.libc.string import memcpy, strcmp + +from av.error import InvalidDataError + +_cinit_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_codec_context( + c_ctx: cython.pointer[lib.AVCodecContext], + c_codec: cython.pointer[cython.const[lib.AVCodec]], + hwaccel: HWAccel, +) -> CodecContext: + """Build an bv.CodecContext for an existing AVCodecContext.""" + py_ctx: CodecContext + + if c_ctx.codec_type == lib.AVMEDIA_TYPE_VIDEO: + from av.video.codeccontext import VideoCodecContext + + py_ctx = VideoCodecContext(_cinit_sentinel) + elif c_ctx.codec_type == lib.AVMEDIA_TYPE_AUDIO: + from av.audio.codeccontext import AudioCodecContext + + py_ctx = AudioCodecContext(_cinit_sentinel) + elif c_ctx.codec_type == lib.AVMEDIA_TYPE_SUBTITLE: + from av.subtitles.codeccontext import SubtitleCodecContext + + py_ctx = SubtitleCodecContext(_cinit_sentinel) + else: + py_ctx = CodecContext(_cinit_sentinel) + + py_ctx._init(c_ctx, c_codec, hwaccel) + + return py_ctx + + +class ThreadType(Flag): + NONE = 0 + FRAME: "Decode more than one frame at once" = lib.FF_THREAD_FRAME + SLICE: "Decode more than one part of a single frame at once" = lib.FF_THREAD_SLICE + AUTO: "Decode using both FRAME and SLICE methods." = ( + lib.FF_THREAD_SLICE | lib.FF_THREAD_FRAME + ) + + +class Flags(IntEnum): + unaligned = lib.AV_CODEC_FLAG_UNALIGNED + qscale = lib.AV_CODEC_FLAG_QSCALE + four_mv = lib.AV_CODEC_FLAG_4MV + output_corrupt = lib.AV_CODEC_FLAG_OUTPUT_CORRUPT + qpel = lib.AV_CODEC_FLAG_QPEL + recon_frame = lib.AV_CODEC_FLAG_RECON_FRAME + copy_opaque = lib.AV_CODEC_FLAG_COPY_OPAQUE + frame_duration = lib.AV_CODEC_FLAG_FRAME_DURATION + pass1 = lib.AV_CODEC_FLAG_PASS1 + pass2 = lib.AV_CODEC_FLAG_PASS2 + loop_filter = lib.AV_CODEC_FLAG_LOOP_FILTER + gray = lib.AV_CODEC_FLAG_GRAY + psnr = lib.AV_CODEC_FLAG_PSNR + interlaced_dct = lib.AV_CODEC_FLAG_INTERLACED_DCT + low_delay = lib.AV_CODEC_FLAG_LOW_DELAY + global_header = lib.AV_CODEC_FLAG_GLOBAL_HEADER + bitexact = lib.AV_CODEC_FLAG_BITEXACT + ac_pred = lib.AV_CODEC_FLAG_AC_PRED + interlaced_me = lib.AV_CODEC_FLAG_INTERLACED_ME + closed_gop = lib.AV_CODEC_FLAG_CLOSED_GOP + + +class Flags2(IntEnum): + fast = lib.AV_CODEC_FLAG2_FAST + no_output = lib.AV_CODEC_FLAG2_NO_OUTPUT + local_header = lib.AV_CODEC_FLAG2_LOCAL_HEADER + chunks = lib.AV_CODEC_FLAG2_CHUNKS + ignore_crop = lib.AV_CODEC_FLAG2_IGNORE_CROP + show_all = lib.AV_CODEC_FLAG2_SHOW_ALL + export_mvs = lib.AV_CODEC_FLAG2_EXPORT_MVS + skip_manual = lib.AV_CODEC_FLAG2_SKIP_MANUAL + ro_flush_noop = lib.AV_CODEC_FLAG2_RO_FLUSH_NOOP + + +class OptionType(IntEnum): + FLAGS = lib.AV_OPT_TYPE_FLAGS + INT = lib.AV_OPT_TYPE_INT + INT64 = lib.AV_OPT_TYPE_INT64 + DOUBLE = lib.AV_OPT_TYPE_DOUBLE + FLOAT = lib.AV_OPT_TYPE_FLOAT + STRING = lib.AV_OPT_TYPE_STRING + RATIONAL = lib.AV_OPT_TYPE_RATIONAL + BINARY = lib.AV_OPT_TYPE_BINARY + DICT = lib.AV_OPT_TYPE_DICT + UINT64 = lib.AV_OPT_TYPE_UINT64 + CONST = lib.AV_OPT_TYPE_CONST + IMAGE_SIZE = lib.AV_OPT_TYPE_IMAGE_SIZE + PIXEL_FMT = lib.AV_OPT_TYPE_PIXEL_FMT + SAMPLE_FMT = lib.AV_OPT_TYPE_SAMPLE_FMT + VIDEO_RATE = lib.AV_OPT_TYPE_VIDEO_RATE + DURATION = lib.AV_OPT_TYPE_DURATION + COLOR = lib.AV_OPT_TYPE_COLOR + CHANNEL_LAYOUT = lib.AV_OPT_TYPE_CHLAYOUT + BOOL = lib.AV_OPT_TYPE_BOOL + UINT = lib.AV_OPT_TYPE_UINT + + +class OptionFlags(IntFlag): + ENCODING_PARAM = lib.AV_OPT_FLAG_ENCODING_PARAM + DECODING_PARAM = lib.AV_OPT_FLAG_DECODING_PARAM + AUDIO_PARAM = lib.AV_OPT_FLAG_AUDIO_PARAM + VIDEO_PARAM = lib.AV_OPT_FLAG_VIDEO_PARAM + SUBTITLE_PARAM = lib.AV_OPT_FLAG_SUBTITLE_PARAM + EXPORT = lib.AV_OPT_FLAG_EXPORT + READONLY = lib.AV_OPT_FLAG_READONLY + BITSTREAM_FILTER_PARAM = lib.AV_OPT_FLAG_BSF_PARAM + RUNTIME_PARAM = lib.AV_OPT_FLAG_RUNTIME_PARAM + FILTERING_PARAM = lib.AV_OPT_FLAG_FILTERING_PARAM + DEPRECATED = lib.AV_OPT_FLAG_DEPRECATED + CHILD_CONSTS = lib.AV_OPT_FLAG_CHILD_CONSTS + + +@dataclass(frozen=True, slots=True) +class CodecOptionChoice: + """A named value accepted by a codec option.""" + + name: str + help: str + + +@dataclass(frozen=True, slots=True) +class CodecOption: + """Description of a generic or codec-specific option.""" + + name: str + help: str + type: OptionType | int + is_array: bool + default: str | None + min: float + max: float + flags: OptionFlags + choices: tuple[CodecOptionChoice, ...] + + +@dataclass(frozen=True, slots=True) +class CodecOptionSet: + """Generic and codec-specific options supported by a codec context.""" + + generic: tuple[CodecOption, ...] + private: tuple[CodecOption, ...] + + +@cython.cfunc +def _get_option_default( + obj: cython.p_void, name: cython.pointer[cython.const[cython.char]] +): + value: cython.pointer[uint8_t] = cython.NULL + if lib.av_opt_get(obj, name, 0, cython.address(value)) < 0: + return None + try: + return cython.cast(cython.p_char, value) if value != cython.NULL else None + finally: + lib.av_free(value) + + +@cython.cfunc +def _get_supported_options(obj: cython.p_void): + options: list = [] + ptr: cython.pointer[cython.const[lib.AVOption]] = lib.av_opt_next(obj, cython.NULL) + choice_ptr: cython.pointer[cython.const[lib.AVOption]] + option_type: object + + while ptr != cython.NULL: + if ptr.type != lib.AV_OPT_TYPE_CONST: + choices: list = [] + if ptr.unit != cython.NULL: + choice_ptr = lib.av_opt_next(obj, cython.NULL) + while choice_ptr != cython.NULL: + if ( + choice_ptr.type == lib.AV_OPT_TYPE_CONST + and choice_ptr.unit != cython.NULL + and strcmp(choice_ptr.unit, ptr.unit) == 0 + ): + choices.append( + CodecOptionChoice( + choice_ptr.name, + choice_ptr.help + if choice_ptr.help != cython.NULL + else "", + ) + ) + choice_ptr = lib.av_opt_next(obj, choice_ptr) + + raw_type = cython.cast(cython.int, ptr.type) + is_array = bool(raw_type & lib.AV_OPT_TYPE_FLAG_ARRAY) + raw_type &= ~lib.AV_OPT_TYPE_FLAG_ARRAY + try: + option_type = OptionType(raw_type) + except ValueError: + option_type = raw_type + + options.append( + CodecOption( + ptr.name, + ptr.help if ptr.help != cython.NULL else "", + option_type, + is_array, + _get_option_default(obj, ptr.name), + ptr.min, + ptr.max, + OptionFlags(ptr.flags), + tuple(choices), + ) + ) + ptr = lib.av_opt_next(obj, ptr) + + return tuple(options) + + +@cython.cclass +class CodecContext: + @staticmethod + def create(codec, mode=None, hwaccel=None): + cy_codec: Codec = codec if isinstance(codec, Codec) else Codec(codec, mode) + c_ctx: cython.pointer[lib.AVCodecContext] = lib.avcodec_alloc_context3( + cy_codec.ptr + ) + return wrap_codec_context(c_ctx, cy_codec.ptr, hwaccel) + + def __cinit__(self, sentinel=None, *args, **kwargs): + if sentinel is not _cinit_sentinel: + raise RuntimeError("Cannot instantiate CodecContext") + + self.options = {} + self.stream_index = -1 # This is set by the container immediately. + self.is_open = False + + @property + def supported_options(self): + """Options supported by this codec context. + + ``generic`` contains options provided by :ffmpeg:`AVCodecContext`, while + ``private`` contains options provided by the selected codec. Values are + descriptors only; set options through :attr:`options`. + """ + ctx: cython.pointer[lib.AVCodecContext] = lib.avcodec_alloc_context3( + self.codec.ptr + ) + child: cython.p_void + private: list = [] + if ctx == cython.NULL: + raise MemoryError("Cannot allocate codec context") + try: + generic = _get_supported_options(ctx) + child = lib.av_opt_child_next(ctx, cython.NULL) + while child != cython.NULL: + private.extend(_get_supported_options(child)) + child = lib.av_opt_child_next(ctx, child) + return CodecOptionSet(generic, tuple(private)) + finally: + lib.avcodec_free_context(cython.address(ctx)) + + @cython.cfunc + def _init( + self, + ptr: cython.pointer[lib.AVCodecContext], + codec: cython.pointer[cython.const[lib.AVCodec]], + hwaccel: HWAccel, + ): + self.ptr = ptr + if self.ptr.codec and codec and self.ptr.codec != codec: + raise RuntimeError("Wrapping CodecContext with mismatched codec.") + self.codec = wrap_codec(codec if codec != cython.NULL else self.ptr.codec) + self.hwaccel = hwaccel + + # Set reasonable threading defaults. + self.ptr.thread_count = 0 # use as many threads as there are CPUs. + self.ptr.thread_type = 0x02 # thread within a frame. Does not change the API. + + @cython.cfunc + def _assert_not_open(self, name): + if self.is_open: + raise RuntimeError(f"Cannot change {name} after codec is open.") + + @property + def flags(self): + """ + Get and set the flags bitmask of CodecContext. + + :rtype: int + """ + return self.ptr.flags + + @flags.setter + def flags(self, value: cython.int): + self.ptr.flags = value + + @property + def qscale(self): + """ + Use fixed qscale. + + :rtype: bool + """ + return bool(self.ptr.flags & lib.AV_CODEC_FLAG_QSCALE) + + @qscale.setter + def qscale(self, value): + if value: + self.ptr.flags |= lib.AV_CODEC_FLAG_QSCALE + else: + self.ptr.flags &= ~lib.AV_CODEC_FLAG_QSCALE + + @property + def copy_opaque(self): + return bool(self.ptr.flags & lib.AV_CODEC_FLAG_COPY_OPAQUE) + + @copy_opaque.setter + def copy_opaque(self, value): + if value: + self.ptr.flags |= lib.AV_CODEC_FLAG_COPY_OPAQUE + else: + self.ptr.flags &= ~lib.AV_CODEC_FLAG_COPY_OPAQUE + + @property + def flags2(self): + """ + Get and set the flags2 bitmask of CodecContext. + + :rtype: int + """ + return self.ptr.flags2 + + @flags2.setter + def flags2(self, value: cython.int): + self.ptr.flags2 = value + + @property + def extradata(self): + if self.ptr is cython.NULL: + return None + if self.ptr.extradata_size > 0: + return cython.cast( + bytes, + cython.cast(cython.pointer[uint8_t], self.ptr.extradata)[ + : self.ptr.extradata_size + ], + ) + return None + + @extradata.setter + def extradata(self, data): + if data is None: + lib.av_freep(cython.address(self.ptr.extradata)) + self.ptr.extradata_size = 0 + else: + source = bytesource(data) + self.ptr.extradata = cython.cast( + cython.pointer[uint8_t], + lib.av_realloc( + self.ptr.extradata, source.length + lib.AV_INPUT_BUFFER_PADDING_SIZE + ), + ) + if not self.ptr.extradata: + raise MemoryError("Cannot allocate extradata") + memcpy(self.ptr.extradata, source.ptr, source.length) + self.ptr.extradata_size = cython.cast(cython.int, source.length) + + @property + def extradata_size(self): + return self.ptr.extradata_size + + @property + def is_encoder(self): + if self.ptr is cython.NULL: + return False + return lib.av_codec_is_encoder(self.ptr.codec) + + @property + def is_decoder(self): + if self.ptr is cython.NULL: + return False + return lib.av_codec_is_decoder(self.ptr.codec) + + @cython.ccall + def open(self, strict: cython.bint = True): + if self.is_open: + if strict: + raise ValueError("CodecContext is already open.") + return + + options: Dictionary = Dictionary() + options.update(self.options or {}) + + if not self.ptr.time_base.num and self.is_encoder: + if self.type == "video": + self.ptr.time_base.num = self.ptr.framerate.den or 1 + self.ptr.time_base.den = self.ptr.framerate.num or lib.AV_TIME_BASE + elif self.type == "audio": + self.ptr.time_base.num = 1 + self.ptr.time_base.den = self.ptr.sample_rate + else: + self.ptr.time_base.num = 1 + self.ptr.time_base.den = lib.AV_TIME_BASE + + self._setup_encode_hwframes() + + err_check( + lib.avcodec_open2(self.ptr, self.codec.ptr, cython.address(options.ptr)), + f'avcodec_open2("{self.codec.name}", {self.options})', + ) + self.is_open = True + self.options = dict(options) + + def __dealloc__(self): + if self.ptr: + lib.av_freep(cython.address(self.ptr.extradata)) + lib.avcodec_free_context(cython.address(self.ptr)) + if self.parser: + lib.av_parser_close(self.parser) + + def __repr__(self): + _type = self.type or "" + name = self.name or "" + return f"" + + def parse(self, raw_input=None): + """Split up a byte stream into list of :class:`.Packet`. + + This is only effectively splitting up a byte stream, and does no + actual interpretation of the data. + + It will return all packets that are fully contained within the given + input, and will buffer partial packets until they are complete. + + Any timing information the parser is able to infer (``pts``, ``dts``, + ``duration``, ``pos`` and the keyframe flag) is assigned onto the + returned packets. Fields the parser cannot determine are left unset. + + :param ByteSource raw_input: A chunk of a byte-stream to process. + Anything that can be turned into a :class:`.ByteSource` is fine. + ``None`` or empty inputs will flush the parser's buffers. + + :return: ``list`` of :class:`.Packet` newly available. + + """ + + if not self.parser: + self.parser = lib.av_parser_init(self.codec.ptr.id) + if not self.parser: + raise ValueError(f"No parser for {self.codec.name}") + + source: ByteSource = bytesource(raw_input, allow_none=True) + + in_data: cython.p_uchar = source.ptr if source is not None else cython.NULL + in_size: cython.int = ( + cython.cast(cython.int, source.length) if source is not None else 0 + ) + + out_data: cython.p_uchar + out_size: cython.int + consumed: cython.int + packet: Packet = None + packets: list = [] + + while True: + with cython.nogil: + consumed = lib.av_parser_parse2( + self.parser, + self.ptr, + cython.address(out_data), + cython.address(out_size), + in_data, + in_size, + lib.AV_NOPTS_VALUE, + lib.AV_NOPTS_VALUE, + 0, + ) + err_check(consumed) + + if out_size: + # We copy the data immediately, as we have yet to figure out + # the expected lifetime of the buffer we get back. All of the + # examples decode it immediately. + # + # We've also tried: + # packet = Packet() + # packet.data = out_data + # packet.size = out_size + # packet.source = source + # + # ... but this results in corruption. + + packet = Packet(out_size) + memcpy(packet.ptr.data, out_data, out_size) + + # Propagate the timing information the parser inferred for + # this frame onto the packet (mirrors FFmpeg's parse_packet). + packet.ptr.pts = self.parser.pts + packet.ptr.dts = self.parser.dts + packet.ptr.pos = self.parser.pos + if self.parser.duration: + packet.ptr.duration = self.parser.duration + if self.parser.key_frame == 1: + packet.ptr.flags |= lib.AV_PKT_FLAG_KEY + + packets.append(packet) + + if not in_size: + # This was a flush. Only one packet should ever be returned. + break + + in_data += consumed + in_size -= consumed + + if not in_size: + break + + return packets + + @property + def is_hwaccel(self): + """ + Returns ``True`` if this codec context is hardware accelerated, ``False`` otherwise. + """ + return self.hwaccel_ctx is not None + + def _send_frame_and_recv(self, frame: Frame | None): + packet: Packet + res: cython.int + with cython.nogil: + res = lib.avcodec_send_frame( + self.ptr, frame.ptr if frame is not None else cython.NULL + ) + err_check(res, "avcodec_send_frame()") + + packet = self._recv_packet() + while packet: + yield packet + packet = self._recv_packet() + + @cython.cfunc + def _setup_encode_hwframes(self) -> cython.void: + # Build the hardware frames context for hardware-accelerated encoding. + # + # Unlike the device context (attached at construction time), the frames + # context depends on the final width/height/pixel format, which the user + # sets after add_stream(). We therefore defer it until just before the + # codec is opened. + if self.hwaccel_ctx is None or not self.is_encoder: + return + if self.ptr.hw_frames_ctx: + return # Already set up. + + hw_format: lib.AVPixelFormat = self.hwaccel_ctx.config.ptr.pix_fmt + sw_format: lib.AVPixelFormat = cython.cast( + lib.AVPixelFormat, self.ptr.sw_pix_fmt + ) + + # The codec context's sw_pix_fmt holds the software format the user + # wants the hardware frames context to use. Fall back to pix_fmt to + # preserve the existing stream.pix_fmt configuration path. + if sw_format == lib.AV_PIX_FMT_NONE: + sw_format = cython.cast(lib.AVPixelFormat, self.ptr.pix_fmt) + + # If they left it as the hardware format (or unset), pick a sane default. + if sw_format == hw_format or sw_format == lib.AV_PIX_FMT_NONE: + sw_format = lib.av_get_pix_fmt(b"nv12") + + frames_ref: cython.pointer[lib.AVBufferRef] = lib.av_hwframe_ctx_alloc( + self.hwaccel_ctx.ptr + ) + if frames_ref == cython.NULL: + raise MemoryError("av_hwframe_ctx_alloc() failed") + + try: + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], frames_ref.data + ) + frames_ctx.format = hw_format + frames_ctx.sw_format = sw_format + frames_ctx.width = self.ptr.width + frames_ctx.height = self.ptr.height + frames_ctx.initial_pool_size = 32 + err_check(lib.av_hwframe_ctx_init(frames_ref)) + except Exception: + lib.av_buffer_unref(cython.address(frames_ref)) + raise + + # Ownership of frames_ref transfers to the codec context. + self.ptr.hw_frames_ctx = frames_ref + self.ptr.sw_pix_fmt = sw_format + self.ptr.pix_fmt = hw_format + + @cython.cfunc + def _prepare_frames_for_encode(self, frame: Frame | None) -> list: + return [frame] + + @cython.cfunc + def _alloc_next_frame(self) -> Frame: + raise NotImplementedError("Base CodecContext cannot decode.") + + @cython.cfunc + def _recv_frame(self): + if not self._next_frame: + self._next_frame = self._alloc_next_frame() + + frame: Frame = self._next_frame + res: cython.int + + with cython.nogil: + res = lib.avcodec_receive_frame(self.ptr, frame.ptr) + + if res == -EAGAIN or res == lib.AVERROR_EOF: + return + + err_check(res, "avcodec_receive_frame()") + frame = self._transfer_hwframe(frame) + + if not res: + self._next_frame = None + return frame + + @cython.cfunc + def _transfer_hwframe(self, frame: Frame): + return frame + + @cython.cfunc + def _recv_packet(self): + packet: Packet = Packet() + res: cython.int + + with cython.nogil: + res = lib.avcodec_receive_packet(self.ptr, packet.ptr) + + if res == -EAGAIN or res == lib.AVERROR_EOF: + return + + err_check(res, "avcodec_receive_packet()") + if not res: + return packet + + @cython.cfunc + def _prepare_and_time_rebase_frames_for_encode(self, frame: Frame): + if self.ptr.codec_type not in [lib.AVMEDIA_TYPE_VIDEO, lib.AVMEDIA_TYPE_AUDIO]: + raise NotImplementedError("Encoding is only supported for audio and video.") + + # A hardware frame (e.g. a CUDA frame from DLPack) carries its own frames + # context. Encoders like h264_nvenc require hw_frames_ctx to be set before + # avcodec_open2, so adopt the frame's if we don't already have one. + if ( + not self.is_open + and frame is not None + and frame.ptr.hw_frames_ctx != cython.NULL + and self.ptr.hw_frames_ctx == cython.NULL + ): + self.ptr.hw_frames_ctx = lib.av_buffer_ref(frame.ptr.hw_frames_ctx) + + self.open(strict=False) + + frames = self._prepare_frames_for_encode(frame) + + # Assert the frames are in our time base. + # TODO: Don't mutate time. + for frame in frames: + if frame is not None: + frame._rebase_time(self.ptr.time_base) + + return frames + + @cython.ccall + def encode(self, frame: Frame | None = None): + """Encode a list of :class:`.Packet` from the given :class:`.Frame`.""" + res = [] + for frame in self._prepare_and_time_rebase_frames_for_encode(frame): + for packet in self._send_frame_and_recv(frame): + self._setup_encoded_packet(packet) + res.append(packet) + return res + + def encode_lazy(self, frame: Frame | None = None): + for frame in self._prepare_and_time_rebase_frames_for_encode(frame): + for packet in self._send_frame_and_recv(frame): + self._setup_encoded_packet(packet) + yield packet + + @cython.cfunc + def _setup_encoded_packet(self, packet: Packet): + # We coerced the frame's time_base into the CodecContext's during encoding, + # and FFmpeg copied the frame's pts/dts to the packet, so keep track of + # this time_base in case the frame needs to be muxed to a container with + # a different time_base. + # + # NOTE: if the CodecContext's time_base is altered during encoding, all bets + # are off! + packet.ptr.time_base = self.ptr.time_base + + @cython.ccall + def decode(self, packet: Packet | None = None): + """Decode a list of :class:`.Frame` from the given :class:`.Packet`. + + If the packet is None, the buffers will be flushed. This is useful if + you do not want the library to automatically re-order frames for you + (if they are encoded with a codec that has B-frames). + + .. warning:: + + This method is **not thread-safe**. Calling :meth:`decode` concurrently + from multiple threads on the same :class:`CodecContext` will corrupt + internal FFmpeg state and likely cause a crash (segfault). FFmpeg 8.1 + enforces this more strictly than earlier releases. If you need to decode + from multiple threads, give each thread its own :class:`CodecContext`. + + """ + return self._decode(packet) + + @cython.cfunc + def _decode(self, packet: Packet | None): + if not self.codec.ptr: + raise ValueError("cannot decode unknown codec") + + self.open(strict=False) + + res: cython.int + with cython.nogil: + res = lib.avcodec_send_packet( + self.ptr, packet.ptr if packet is not None else cython.NULL + ) + err_check(res, "avcodec_send_packet()") + + out: list = [] + while True: + try: + frame = self._recv_frame() + except InvalidDataError: + if out: + break + raise + if frame is None: + break + self._setup_decoded_frame(frame, packet) + out.append(frame) + return out + + @cython.ccall + def flush_buffers(self): + """Reset the internal codec state and discard all internal buffers. + + Should be called before you start decoding from a new position e.g. + when seeking or when switching to a different stream. + + """ + if self.is_open: + with cython.nogil: + lib.avcodec_flush_buffers(self.ptr) + + @cython.cfunc + def _setup_decoded_frame(self, frame: Frame, packet: Packet | None): + # Propagate our manual times. + # While decoding, frame times are in stream time_base, which PyAV + # is carrying around. + # TODO: Somehow get this from the stream so we can not pass the + # packet here (because flushing packets are bogus). + if packet is not None: + frame._time_base = packet.ptr.time_base + + @property + def name(self): + return self.codec.name + + @property + def type(self): + return self.codec.type + + @property + def profiles(self): + """ + List the available profiles for this stream. + + :type: list[str] + """ + ret: list = [] + if not self.ptr.codec or not self.codec.desc or not self.codec.desc.profiles: + return ret + + # Profiles are always listed in the codec descriptor, but not necessarily in + # the codec itself. So use the descriptor here. + desc = self.codec.desc + i: cython.int = 0 + while desc.profiles[i].profile != lib.AV_PROFILE_UNKNOWN: + ret.append(desc.profiles[i].name) + i += 1 + + return ret + + @property + def profile(self): + if not self.ptr.codec or not self.codec.desc or not self.codec.desc.profiles: + return + + # Profiles are always listed in the codec descriptor, but not necessarily in + # the codec itself. So use the descriptor here. + desc = self.codec.desc + i: cython.int = 0 + while desc.profiles[i].profile != lib.AV_PROFILE_UNKNOWN: + if desc.profiles[i].profile == self.ptr.profile: + return desc.profiles[i].name + i += 1 + + @profile.setter + def profile(self, value): + if not self.codec or not self.codec.desc or not self.codec.desc.profiles: + return + + # Profiles are always listed in the codec descriptor, but not necessarily in + # the codec itself. So use the descriptor here. + desc = self.codec.desc + i: cython.int = 0 + while desc.profiles[i].profile != lib.AV_PROFILE_UNKNOWN: + if desc.profiles[i].name == value: + self.ptr.profile = desc.profiles[i].profile + return + i += 1 + + @property + def level(self): + """Codec level. + + Wraps :ffmpeg:`AVCodecContext.level`. + + """ + return self.ptr.level + + @level.setter + def level(self, value: cython.int): + self.ptr.level = value + + @property + def time_base(self): + if self.is_decoder: + raise RuntimeError("Cannot access 'time_base' as a decoder") + return avrational_to_fraction(cython.address(self.ptr.time_base)) + + @time_base.setter + def time_base(self, value): + if self.is_decoder: + raise RuntimeError("Cannot access 'time_base' as a decoder") + to_avrational(value, cython.address(self.ptr.time_base)) + + @property + def codec_tag(self): + return self.ptr.codec_tag.to_bytes(4, byteorder="little", signed=False).decode( + encoding="ascii" + ) + + @codec_tag.setter + def codec_tag(self, value): + if isinstance(value, str) and len(value) == 4: + self.ptr.codec_tag = int.from_bytes( + value.encode(encoding="ascii"), byteorder="little", signed=False + ) + else: + raise ValueError("Codec tag should be a 4 character string.") + + @property + @cython.cdivision(True) + def global_quality(self): + """Global quality for codecs which cannot change it per frame. + + Stored internally in lambda units; this property converts to/from + QP units using ``FF_QP2LAMBDA``. + + Wraps :ffmpeg:`AVCodecContext.global_quality`. + + """ + return self.ptr.global_quality // lib.FF_QP2LAMBDA + + @global_quality.setter + def global_quality(self, value: cython.int): + self.ptr.global_quality = value * lib.FF_QP2LAMBDA + + @property + def bit_rate(self): + return self.ptr.bit_rate if self.ptr.bit_rate > 0 else None + + @bit_rate.setter + def bit_rate(self, value: cython.longlong): + self.ptr.bit_rate = value + + @property + def max_bit_rate(self): + if self.ptr.rc_max_rate > 0: + return self.ptr.rc_max_rate + else: + return None + + @property + def bit_rate_tolerance(self): + self.ptr.bit_rate_tolerance + + @bit_rate_tolerance.setter + def bit_rate_tolerance(self, value: cython.int): + self.ptr.bit_rate_tolerance = value + + @property + def thread_count(self): + """How many threads to use; 0 means auto. + + Wraps :ffmpeg:`AVCodecContext.thread_count`. + + """ + return self.ptr.thread_count + + @thread_count.setter + def thread_count(self, value: cython.int): + if self.is_open: + raise RuntimeError("Cannot change thread_count after codec is open.") + self.ptr.thread_count = value + + @property + def thread_type(self): + """One of :class:`.ThreadType`. + + Wraps :ffmpeg:`AVCodecContext.thread_type`. + + """ + return ThreadType(self.ptr.thread_type) + + @thread_type.setter + def thread_type(self, value): + if self.is_open: + raise RuntimeError("Cannot change thread_type after codec is open.") + if type(value) is int: + self.ptr.thread_type = value + elif type(value) is str: + self.ptr.thread_type = ThreadType[value].value + else: + self.ptr.thread_type = value.value + + @property + def skip_frame(self): + """Returns one of the following str literals: + + "NONE" Discard nothing + "DEFAULT" Discard useless packets like 0 size packets in AVI + "NONREF" Discard all non reference + "BIDIR" Discard all bidirectional frames + "NONINTRA" Discard all non intra frames + "NONKEY Discard all frames except keyframes + "ALL" Discard all + + Wraps :ffmpeg:`AVCodecContext.skip_frame`. + """ + value = self.ptr.skip_frame + if value == lib.AVDISCARD_NONE: + return "NONE" + if value == lib.AVDISCARD_DEFAULT: + return "DEFAULT" + if value == lib.AVDISCARD_NONREF: + return "NONREF" + if value == lib.AVDISCARD_BIDIR: + return "BIDIR" + if value == lib.AVDISCARD_NONINTRA: + return "NONINTRA" + if value == lib.AVDISCARD_NONKEY: + return "NONKEY" + if value == lib.AVDISCARD_ALL: + return "ALL" + return f"{value}" + + @skip_frame.setter + def skip_frame(self, value): + if value == "NONE": + self.ptr.skip_frame = lib.AVDISCARD_NONE + elif value == "DEFAULT": + self.ptr.skip_frame = lib.AVDISCARD_DEFAULT + elif value == "NONREF": + self.ptr.skip_frame = lib.AVDISCARD_NONREF + elif value == "BIDIR": + self.ptr.skip_frame = lib.AVDISCARD_BIDIR + elif value == "NONINTRA": + self.ptr.skip_frame = lib.AVDISCARD_NONINTRA + elif value == "NONKEY": + self.ptr.skip_frame = lib.AVDISCARD_NONKEY + elif value == "ALL": + self.ptr.skip_frame = lib.AVDISCARD_ALL + else: + raise ValueError("Invalid skip_frame type") + + @property + def delay(self): + """Codec delay. + + Wraps :ffmpeg:`AVCodecContext.delay`. + + """ + return self.ptr.delay diff --git a/av/codec/context.pyi b/av/codec/context.pyi new file mode 100644 index 000000000..3dfb52285 --- /dev/null +++ b/av/codec/context.pyi @@ -0,0 +1,189 @@ +from dataclasses import dataclass +from enum import Flag, IntEnum, IntFlag +from fractions import Fraction +from typing import ClassVar, Literal, cast, overload + +from av.audio import _AudioCodecName +from av.audio.codeccontext import AudioCodecContext +from av.packet import Packet +from av.subtitles import _SubtitleCodecName +from av.subtitles.codeccontext import SubtitleCodecContext +from av.video import _VideoCodecName +from av.video.codeccontext import VideoCodecContext + +from .codec import Codec +from .hwaccel import HWAccel + +class ThreadType(Flag): + NONE = cast(ClassVar[ThreadType], ...) + FRAME = cast(ClassVar[ThreadType], ...) + SLICE = cast(ClassVar[ThreadType], ...) + AUTO = cast(ClassVar[ThreadType], ...) + def __get__(self, i: object | None, owner: type | None = None) -> ThreadType: ... + def __set__(self, instance: object, value: int | str | ThreadType) -> None: ... + +class Flags(IntEnum): + unaligned = cast(int, ...) + qscale = cast(int, ...) + four_mv = cast(int, ...) + output_corrupt = cast(int, ...) + qpel = cast(int, ...) + recon_frame = cast(int, ...) + copy_opaque = cast(int, ...) + frame_duration = cast(int, ...) + pass1 = cast(int, ...) + pass2 = cast(int, ...) + loop_filter = cast(int, ...) + gray = cast(int, ...) + psnr = cast(int, ...) + interlaced_dct = cast(int, ...) + low_delay = cast(int, ...) + global_header = cast(int, ...) + bitexact = cast(int, ...) + ac_pred = cast(int, ...) + interlaced_me = cast(int, ...) + closed_gop = cast(int, ...) + +class Flags2(IntEnum): + fast = cast(int, ...) + no_output = cast(int, ...) + local_header = cast(int, ...) + chunks = cast(int, ...) + ignore_crop = cast(int, ...) + show_all = cast(int, ...) + export_mvs = cast(int, ...) + skip_manual = cast(int, ...) + ro_flush_noop = cast(int, ...) + +class OptionType(IntEnum): + FLAGS = cast(int, ...) + INT = cast(int, ...) + INT64 = cast(int, ...) + DOUBLE = cast(int, ...) + FLOAT = cast(int, ...) + STRING = cast(int, ...) + RATIONAL = cast(int, ...) + BINARY = cast(int, ...) + DICT = cast(int, ...) + UINT64 = cast(int, ...) + CONST = cast(int, ...) + IMAGE_SIZE = cast(int, ...) + PIXEL_FMT = cast(int, ...) + SAMPLE_FMT = cast(int, ...) + VIDEO_RATE = cast(int, ...) + DURATION = cast(int, ...) + COLOR = cast(int, ...) + CHANNEL_LAYOUT = cast(int, ...) + BOOL = cast(int, ...) + UINT = cast(int, ...) + +class OptionFlags(IntFlag): + ENCODING_PARAM = cast(int, ...) + DECODING_PARAM = cast(int, ...) + AUDIO_PARAM = cast(int, ...) + VIDEO_PARAM = cast(int, ...) + SUBTITLE_PARAM = cast(int, ...) + EXPORT = cast(int, ...) + READONLY = cast(int, ...) + BITSTREAM_FILTER_PARAM = cast(int, ...) + RUNTIME_PARAM = cast(int, ...) + FILTERING_PARAM = cast(int, ...) + DEPRECATED = cast(int, ...) + CHILD_CONSTS = cast(int, ...) + +@dataclass(frozen=True, slots=True) +class CodecOptionChoice: + name: str + help: str + +@dataclass(frozen=True, slots=True) +class CodecOption: + name: str + help: str + type: OptionType | int + is_array: bool + default: str | None + min: float + max: float + flags: OptionFlags + choices: tuple[CodecOptionChoice, ...] + +@dataclass(frozen=True, slots=True) +class CodecOptionSet: + generic: tuple[CodecOption, ...] + private: tuple[CodecOption, ...] + +class CodecContext: + name: str + type: Literal["video", "audio", "data", "subtitle", "attachment"] + options: dict[str, str] + @property + def supported_options(self) -> CodecOptionSet: ... + profile: str | None + level: int + @property + def profiles(self) -> list[str]: ... + extradata: bytes | None + time_base: Fraction + codec_tag: str + global_quality: int + bit_rate: int | None + bit_rate_tolerance: int + thread_count: int + thread_type: ThreadType + skip_frame: Literal[ + "NONE", "DEFAULT", "NONREF", "BIDIR", "NONINTRA", "NONKEY", "ALL" + ] + flags: int + qscale: bool + copy_opaque: bool + flags2: int + @property + def is_open(self) -> bool: ... + @property + def is_encoder(self) -> bool: ... + @property + def is_decoder(self) -> bool: ... + @property + def codec(self) -> Codec: ... + @property + def max_bit_rate(self) -> int | None: ... + @property + def delay(self) -> bool: ... + @property + def extradata_size(self) -> int: ... + @property + def is_hwaccel(self) -> bool: ... + def open(self, strict: bool = True) -> None: ... + @overload + @staticmethod + def create( + codec: _AudioCodecName, + mode: Literal["r", "w"] | None = None, + hwaccel: HWAccel | None = None, + ) -> AudioCodecContext: ... + @overload + @staticmethod + def create( + codec: _VideoCodecName, + mode: Literal["r", "w"] | None = None, + hwaccel: HWAccel | None = None, + ) -> VideoCodecContext: ... + @overload + @staticmethod + def create( + codec: _SubtitleCodecName, + mode: Literal["r", "w"] | None = None, + hwaccel: HWAccel | None = None, + ) -> SubtitleCodecContext: ... + @overload + @staticmethod + def create( + codec: str | Codec, + mode: Literal["r", "w"] | None = None, + hwaccel: HWAccel | None = None, + ) -> CodecContext: ... + def parse( + self, raw_input: bytes | bytearray | memoryview | None = None + ) -> list[Packet]: ... + def flush_buffers(self) -> None: ... diff --git a/av/codec/context.pyx b/av/codec/context.pyx deleted file mode 100644 index d93fb64f5..000000000 --- a/av/codec/context.pyx +++ /dev/null @@ -1,633 +0,0 @@ -from libc.errno cimport EAGAIN -from libc.stdint cimport int64_t, uint8_t -from libc.string cimport memcpy -cimport libav as lib - -from av.bytesource cimport ByteSource, bytesource -from av.codec.codec cimport Codec, wrap_codec -from av.dictionary cimport _Dictionary -from av.enum cimport define_enum -from av.error cimport err_check -from av.packet cimport Packet -from av.utils cimport avrational_to_fraction, to_avrational - -from av.dictionary import Dictionary - - -cdef object _cinit_sentinel = object() - - -cdef CodecContext wrap_codec_context(lib.AVCodecContext *c_ctx, const lib.AVCodec *c_codec, bint allocated): - """Build an av.CodecContext for an existing AVCodecContext.""" - - cdef CodecContext py_ctx - - # TODO: This. - if c_ctx.codec_type == lib.AVMEDIA_TYPE_VIDEO: - from av.video.codeccontext import VideoCodecContext - py_ctx = VideoCodecContext(_cinit_sentinel) - elif c_ctx.codec_type == lib.AVMEDIA_TYPE_AUDIO: - from av.audio.codeccontext import AudioCodecContext - py_ctx = AudioCodecContext(_cinit_sentinel) - elif c_ctx.codec_type == lib.AVMEDIA_TYPE_SUBTITLE: - from av.subtitles.codeccontext import SubtitleCodecContext - py_ctx = SubtitleCodecContext(_cinit_sentinel) - else: - py_ctx = CodecContext(_cinit_sentinel) - - py_ctx.allocated = allocated - py_ctx._init(c_ctx, c_codec) - - return py_ctx - - -ThreadType = define_enum('ThreadType', __name__, ( - ('NONE', 0), - ('FRAME', lib.FF_THREAD_FRAME, - """Decode more than one frame at once"""), - ('SLICE', lib.FF_THREAD_SLICE, - """Decode more than one part of a single frame at once"""), - ('AUTO', lib.FF_THREAD_SLICE | lib.FF_THREAD_FRAME, - """Either method."""), -), is_flags=True) - -SkipType = define_enum('SkipType', __name__, ( - ('NONE', lib.AVDISCARD_NONE, - """Discard nothing"""), - ('DEFAULT', lib.AVDISCARD_DEFAULT, - """Discard useless packets like 0 size packets in AVI"""), - ('NONREF', lib.AVDISCARD_NONREF, - """Discard all non reference"""), - ('BIDIR', lib.AVDISCARD_BIDIR, - """Discard all bidirectional frames"""), - ('NONINTRA', lib.AVDISCARD_NONINTRA, - """Discard all non intra frames"""), - ('NONKEY', lib.AVDISCARD_NONKEY, - """Discard all frames except keyframes"""), - ('ALL', lib.AVDISCARD_ALL, - """Discard all"""), -)) - -Flags = define_enum('Flags', __name__, ( - ('NONE', 0), - ('UNALIGNED', lib.AV_CODEC_FLAG_UNALIGNED, - """Allow decoders to produce frames with data planes that are not aligned - to CPU requirements (e.g. due to cropping)."""), - ('QSCALE', lib.AV_CODEC_FLAG_QSCALE, - """Use fixed qscale."""), - ('4MV', lib.AV_CODEC_FLAG_4MV, - """4 MV per MB allowed / advanced prediction for H.263."""), - ('OUTPUT_CORRUPT', lib.AV_CODEC_FLAG_OUTPUT_CORRUPT, - """Output even those frames that might be corrupted."""), - ('QPEL', lib.AV_CODEC_FLAG_QPEL, - """Use qpel MC."""), - ('DROPCHANGED', 1 << 5, - """Don't output frames whose parameters differ from first - decoded frame in stream."""), - ('PASS1', lib.AV_CODEC_FLAG_PASS1, - """Use internal 2pass ratecontrol in first pass mode."""), - ('PASS2', lib.AV_CODEC_FLAG_PASS2, - """Use internal 2pass ratecontrol in second pass mode."""), - ('LOOP_FILTER', lib.AV_CODEC_FLAG_LOOP_FILTER, - """loop filter."""), - ('GRAY', lib.AV_CODEC_FLAG_GRAY, - """Only decode/encode grayscale."""), - ('PSNR', lib.AV_CODEC_FLAG_PSNR, - """error[?] variables will be set during encoding."""), - ('TRUNCATED', lib.AV_CODEC_FLAG_TRUNCATED, - """Input bitstream might be truncated at a random location - instead of only at frame boundaries."""), - ('INTERLACED_DCT', lib.AV_CODEC_FLAG_INTERLACED_DCT, - """Use interlaced DCT."""), - ('LOW_DELAY', lib.AV_CODEC_FLAG_LOW_DELAY, - """Force low delay."""), - ('GLOBAL_HEADER', lib.AV_CODEC_FLAG_GLOBAL_HEADER, - """Place global headers in extradata instead of every keyframe."""), - ('BITEXACT', lib.AV_CODEC_FLAG_BITEXACT, - """Use only bitexact stuff (except (I)DCT)."""), - ('AC_PRED', lib.AV_CODEC_FLAG_AC_PRED, - """H.263 advanced intra coding / MPEG-4 AC prediction"""), - ('INTERLACED_ME', lib.AV_CODEC_FLAG_INTERLACED_ME, - """Interlaced motion estimation"""), - ('CLOSED_GOP', lib.AV_CODEC_FLAG_CLOSED_GOP), -), is_flags=True) - -Flags2 = define_enum('Flags2', __name__, ( - ('NONE', 0), - ('FAST', lib.AV_CODEC_FLAG2_FAST, - """Allow non spec compliant speedup tricks."""), - ('NO_OUTPUT', lib.AV_CODEC_FLAG2_NO_OUTPUT, - """Skip bitstream encoding."""), - ('LOCAL_HEADER', lib.AV_CODEC_FLAG2_LOCAL_HEADER, - """Place global headers at every keyframe instead of in extradata."""), - ('DROP_FRAME_TIMECODE', lib.AV_CODEC_FLAG2_DROP_FRAME_TIMECODE, - """Timecode is in drop frame format. DEPRECATED!!!!"""), - ('CHUNKS', lib.AV_CODEC_FLAG2_CHUNKS, - """Input bitstream might be truncated at a packet boundaries - instead of only at frame boundaries."""), - ('IGNORE_CROP', lib.AV_CODEC_FLAG2_IGNORE_CROP, - """Discard cropping information from SPS."""), - ('SHOW_ALL', lib.AV_CODEC_FLAG2_SHOW_ALL, - """Show all frames before the first keyframe"""), - ('EXPORT_MVS', lib.AV_CODEC_FLAG2_EXPORT_MVS, - """Export motion vectors through frame side data"""), - ('SKIP_MANUAL', lib.AV_CODEC_FLAG2_SKIP_MANUAL, - """Do not skip samples and export skip information as frame side data"""), - ('RO_FLUSH_NOOP', lib.AV_CODEC_FLAG2_RO_FLUSH_NOOP, - """Do not reset ASS ReadOrder field on flush (subtitles decoding)"""), -), is_flags=True) - - -cdef class CodecContext(object): - - @staticmethod - def create(codec, mode=None): - cdef Codec cy_codec = codec if isinstance(codec, Codec) else Codec(codec, mode) - cdef lib.AVCodecContext *c_ctx = lib.avcodec_alloc_context3(cy_codec.ptr) - return wrap_codec_context(c_ctx, cy_codec.ptr, True) - - def __cinit__(self, sentinel=None, *args, **kwargs): - if sentinel is not _cinit_sentinel: - raise RuntimeError('Cannot instantiate CodecContext') - - self.options = {} - self.stream_index = -1 # This is set by the container immediately. - - cdef _init(self, lib.AVCodecContext *ptr, const lib.AVCodec *codec): - - self.ptr = ptr - if self.ptr.codec and codec and self.ptr.codec != codec: - raise RuntimeError('Wrapping CodecContext with mismatched codec.') - self.codec = wrap_codec(codec if codec != NULL else self.ptr.codec) - - # Set reasonable threading defaults. - # count == 0 -> use as many threads as there are CPUs. - # type == 2 -> thread within a frame. This does not change the API. - self.ptr.thread_count = 0 - self.ptr.thread_type = 2 - - def _get_flags(self): - return self.ptr.flags - - def _set_flags(self, value): - self.ptr.flags = value - - flags = Flags.property( - _get_flags, - _set_flags, - """Flag property of :class:`.Flags`.""" - ) - - unaligned = flags.flag_property('UNALIGNED') - qscale = flags.flag_property('QSCALE') - four_mv = flags.flag_property('4MV') - output_corrupt = flags.flag_property('OUTPUT_CORRUPT') - qpel = flags.flag_property('QPEL') - drop_changed = flags.flag_property('DROPCHANGED') - pass1 = flags.flag_property('PASS1') - pass2 = flags.flag_property('PASS2') - loop_filter = flags.flag_property('LOOP_FILTER') - gray = flags.flag_property('GRAY') - psnr = flags.flag_property('PSNR') - truncated = flags.flag_property('TRUNCATED') - interlaced_dct = flags.flag_property('INTERLACED_DCT') - low_delay = flags.flag_property('LOW_DELAY') - global_header = flags.flag_property('GLOBAL_HEADER') - bitexact = flags.flag_property('BITEXACT') - ac_pred = flags.flag_property('AC_PRED') - interlaced_me = flags.flag_property('INTERLACED_ME') - closed_gop = flags.flag_property('CLOSED_GOP') - - def _get_flags2(self): - return self.ptr.flags2 - - def _set_flags2(self, value): - self.ptr.flags2 = value - - flags2 = Flags2.property( - _get_flags2, - _set_flags2, - """Flag property of :class:`.Flags2`.""" - ) - - fast = flags2.flag_property('FAST') - no_output = flags2.flag_property('NO_OUTPUT') - local_header = flags2.flag_property('LOCAL_HEADER') - drop_frame_timecode = flags2.flag_property('DROP_FRAME_TIMECODE') - chunks = flags2.flag_property('CHUNKS') - ignore_crop = flags2.flag_property('IGNORE_CROP') - show_all = flags2.flag_property('SHOW_ALL') - export_mvs = flags2.flag_property('EXPORT_MVS') - skip_manual = flags2.flag_property('SKIP_MANUAL') - ro_flush_noop = flags2.flag_property('RO_FLUSH_NOOP') - - property extradata: - def __get__(self): - if self.ptr.extradata_size > 0: - return (self.ptr.extradata)[:self.ptr.extradata_size] - else: - return None - - def __set__(self, data): - if not self.is_decoder: - raise ValueError("Can only set extradata for decoders.") - - if data is None: - lib.av_freep(&self.ptr.extradata) - self.ptr.extradata_size = 0 - else: - source = bytesource(data) - self.ptr.extradata = lib.av_realloc(self.ptr.extradata, source.length + lib.AV_INPUT_BUFFER_PADDING_SIZE) - if not self.ptr.extradata: - raise MemoryError("Cannot allocate extradata") - memcpy(self.ptr.extradata, source.ptr, source.length) - self.ptr.extradata_size = source.length - self.extradata_set = True - - property extradata_size: - def __get__(self): - return self.ptr.extradata_size - - property is_open: - def __get__(self): - return lib.avcodec_is_open(self.ptr) - - property is_encoder: - def __get__(self): - return lib.av_codec_is_encoder(self.ptr.codec) - - property is_decoder: - def __get__(self): - return lib.av_codec_is_decoder(self.ptr.codec) - - cpdef open(self, bint strict=True): - - if lib.avcodec_is_open(self.ptr): - if strict: - raise ValueError('CodecContext is already open.') - return - - # We might pass partial frames. - # TODO: What is this for?! This is causing problems with raw decoding - # as the internal parser doesn't seem to see a frame until it sees - # the next one. - # if self.codec.ptr.capabilities & lib.CODEC_CAP_TRUNCATED: - # self.ptr.flags |= lib.CODEC_FLAG_TRUNCATED - - # TODO: Do this better. - cdef _Dictionary options = Dictionary() - options.update(self.options or {}) - - # Assert we have a time_base. - if not self.ptr.time_base.num: - self._set_default_time_base() - - err_check(lib.avcodec_open2(self.ptr, self.codec.ptr, &options.ptr)) - - self.options = dict(options) - - cdef _set_default_time_base(self): - self.ptr.time_base.num = 1 - self.ptr.time_base.den = lib.AV_TIME_BASE - - cpdef close(self, bint strict=True): - if not lib.avcodec_is_open(self.ptr): - if strict: - raise ValueError('CodecContext is already closed.') - return - err_check(lib.avcodec_close(self.ptr)) - - def __dealloc__(self): - if self.ptr and self.extradata_set: - lib.av_freep(&self.ptr.extradata) - if self.ptr and self.allocated: - lib.avcodec_close(self.ptr) - lib.avcodec_free_context(&self.ptr) - if self.parser: - lib.av_parser_close(self.parser) - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.type or '', - self.name or '', - id(self), - ) - - def parse(self, raw_input=None): - """Split up a byte stream into list of :class:`.Packet`. - - This is only effectively splitting up a byte stream, and does no - actual interpretation of the data. - - It will return all packets that are fully contained within the given - input, and will buffer partial packets until they are complete. - - :param ByteSource raw_input: A chunk of a byte-stream to process. - Anything that can be turned into a :class:`.ByteSource` is fine. - ``None`` or empty inputs will flush the parser's buffers. - - :return: ``list`` of :class:`.Packet` newly available. - - """ - - if not self.parser: - self.parser = lib.av_parser_init(self.codec.ptr.id) - if not self.parser: - raise ValueError('No parser for %s' % self.codec.name) - - cdef ByteSource source = bytesource(raw_input, allow_none=True) - - cdef unsigned char *in_data = source.ptr if source is not None else NULL - cdef int in_size = source.length if source is not None else 0 - - cdef unsigned char *out_data - cdef int out_size - cdef int consumed - cdef Packet packet = None - - packets = [] - - while True: - - with nogil: - consumed = lib.av_parser_parse2( - self.parser, - self.ptr, - &out_data, &out_size, - in_data, in_size, - lib.AV_NOPTS_VALUE, lib.AV_NOPTS_VALUE, - 0 - ) - err_check(consumed) - - if out_size: - - # We copy the data immediately, as we have yet to figure out - # the expected lifetime of the buffer we get back. All of the - # examples decode it immediately. - # - # We've also tried: - # packet = Packet() - # packet.data = out_data - # packet.size = out_size - # packet.source = source - # - # ... but this results in corruption. - - packet = Packet(out_size) - memcpy(packet.struct.data, out_data, out_size) - - packets.append(packet) - - if not in_size: - # This was a flush. Only one packet should ever be returned. - break - - in_data += consumed - in_size -= consumed - - if not in_size: - # Aaaand now we're done. - break - - return packets - - cdef _send_frame_and_recv(self, Frame frame): - - cdef Packet packet - - cdef int res - with nogil: - res = lib.avcodec_send_frame(self.ptr, frame.ptr if frame is not None else NULL) - err_check(res) - - out = [] - while True: - packet = self._recv_packet() - if packet: - out.append(packet) - else: - break - return out - - cdef _send_packet_and_recv(self, Packet packet): - - cdef Frame frame - - cdef int res - with nogil: - res = lib.avcodec_send_packet(self.ptr, &packet.struct if packet is not None else NULL) - err_check(res) - - out = [] - while True: - frame = self._recv_frame() - if frame: - out.append(frame) - else: - break - return out - - cdef _prepare_frames_for_encode(self, Frame frame): - return [frame] - - cdef Frame _alloc_next_frame(self): - raise NotImplementedError('Base CodecContext cannot decode.') - - cdef _recv_frame(self): - - if not self._next_frame: - self._next_frame = self._alloc_next_frame() - cdef Frame frame = self._next_frame - - cdef int res - with nogil: - res = lib.avcodec_receive_frame(self.ptr, frame.ptr) - - if res == -EAGAIN or res == lib.AVERROR_EOF: - return - err_check(res) - - if not res: - self._next_frame = None - return frame - - cdef _recv_packet(self): - - cdef Packet packet = Packet() - - cdef int res - with nogil: - res = lib.avcodec_receive_packet(self.ptr, &packet.struct) - if res == -EAGAIN or res == lib.AVERROR_EOF: - return - err_check(res) - - if not res: - return packet - - cpdef encode(self, Frame frame=None): - """Encode a list of :class:`.Packet` from the given :class:`.Frame`.""" - - if self.ptr.codec_type not in [lib.AVMEDIA_TYPE_VIDEO, lib.AVMEDIA_TYPE_AUDIO]: - raise NotImplementedError('Encoding is only supported for audio and video.') - - self.open(strict=False) - - frames = self._prepare_frames_for_encode(frame) - - # Assert the frames are in our time base. - # TODO: Don't mutate time. - for frame in frames: - if frame is not None: - frame._rebase_time(self.ptr.time_base) - - res = [] - for frame in frames: - for packet in self._send_frame_and_recv(frame): - self._setup_encoded_packet(packet) - res.append(packet) - return res - - cdef _setup_encoded_packet(self, Packet packet): - # We coerced the frame's time_base into the CodecContext's during encoding, - # and FFmpeg copied the frame's pts/dts to the packet, so keep track of - # this time_base in case the frame needs to be muxed to a container with - # a different time_base. - # - # NOTE: if the CodecContext's time_base is altered during encoding, all bets - # are off! - packet._time_base = self.ptr.time_base - - cpdef decode(self, Packet packet=None): - """Decode a list of :class:`.Frame` from the given :class:`.Packet`. - - If the packet is None, the buffers will be flushed. This is useful if - you do not want the library to automatically re-order frames for you - (if they are encoded with a codec that has B-frames). - - """ - - if not self.codec.ptr: - raise ValueError('cannot decode unknown codec') - - self.open(strict=False) - - res = [] - for frame in self._send_packet_and_recv(packet): - if isinstance(frame, Frame): - self._setup_decoded_frame(frame, packet) - res.append(frame) - return res - - cdef _setup_decoded_frame(self, Frame frame, Packet packet): - - # Propagate our manual times. - # While decoding, frame times are in stream time_base, which PyAV - # is carrying around. - # TODO: Somehow get this from the stream so we can not pass the - # packet here (because flushing packets are bogus). - frame._time_base = packet._time_base - - frame.index = self.ptr.frame_number - 1 - - property name: - def __get__(self): - return self.codec.name - - property type: - def __get__(self): - return self.codec.type - - property profile: - def __get__(self): - if self.ptr.codec and lib.av_get_profile_name(self.ptr.codec, self.ptr.profile): - return lib.av_get_profile_name(self.ptr.codec, self.ptr.profile) - - property time_base: - def __get__(self): - return avrational_to_fraction(&self.ptr.time_base) - - def __set__(self, value): - to_avrational(value, &self.ptr.time_base) - - property codec_tag: - def __get__(self): - return self.ptr.codec_tag.to_bytes(4, byteorder="little", signed=False).decode( - encoding="ascii") - - def __set__(self, value): - if isinstance(value, str) and len(value) == 4: - self.ptr.codec_tag = int.from_bytes(value.encode(encoding="ascii"), - byteorder="little", signed=False) - else: - raise ValueError("Codec tag should be a 4 character string.") - - property ticks_per_frame: - def __get__(self): - return self.ptr.ticks_per_frame - - property bit_rate: - def __get__(self): - return self.ptr.bit_rate if self.ptr.bit_rate > 0 else None - - def __set__(self, int value): - self.ptr.bit_rate = value - - property max_bit_rate: - def __get__(self): - if self.ptr.rc_max_rate > 0: - return self.ptr.rc_max_rate - else: - return None - - property bit_rate_tolerance: - def __get__(self): - self.ptr.bit_rate_tolerance - - def __set__(self, int value): - self.ptr.bit_rate_tolerance = value - - property thread_count: - """How many threads to use; 0 means auto. - - Wraps :ffmpeg:`AVCodecContext.thread_count`. - - """ - - def __get__(self): - return self.ptr.thread_count - - def __set__(self, int value): - if lib.avcodec_is_open(self.ptr): - raise RuntimeError("Cannot change thread_count after codec is open.") - self.ptr.thread_count = value - - property thread_type: - """One of :class:`.ThreadType`. - - Wraps :ffmpeg:`AVCodecContext.thread_type`. - - """ - - def __get__(self): - return ThreadType.get(self.ptr.thread_type, create=True) - - def __set__(self, value): - if lib.avcodec_is_open(self.ptr): - raise RuntimeError("Cannot change thread_type after codec is open.") - self.ptr.thread_type = ThreadType[value].value - - property skip_frame: - """One of :class:`.SkipType`. - - Wraps ffmpeg:`AVCodecContext.skip_frame`. - - """ - - def __get__(self): - return SkipType._get(self.ptr.skip_frame, create=True) - - def __set__(self, value): - self.ptr.skip_frame = SkipType[value].value diff --git a/av/codec/hwaccel.pxd b/av/codec/hwaccel.pxd new file mode 100644 index 000000000..fd9bb1720 --- /dev/null +++ b/av/codec/hwaccel.pxd @@ -0,0 +1,23 @@ +cimport libav as lib + +from av.codec.codec cimport Codec + + +cdef class HWConfig: + cdef object __weakref__ + cdef const lib.AVCodecHWConfig *ptr + cdef void _init(self, const lib.AVCodecHWConfig *ptr) + +cdef HWConfig wrap_hwconfig(const lib.AVCodecHWConfig *ptr) + +cdef class HWAccel: + cdef int _device_type + cdef str _device + cdef readonly Codec codec + cdef readonly HWConfig config + cdef lib.AVBufferRef *ptr + cdef readonly int device_id + cdef readonly bint is_hw_owned + cdef public bint allow_software_fallback + cdef public dict options + cdef public int flags diff --git a/av/codec/hwaccel.py b/av/codec/hwaccel.py new file mode 100644 index 000000000..93e7accec --- /dev/null +++ b/av/codec/hwaccel.py @@ -0,0 +1,220 @@ +import weakref +from enum import IntEnum + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.codec.codec import Codec +from cython.cimports.av.dictionary import Dictionary +from cython.cimports.av.error import err_check +from cython.cimports.av.video.format import get_video_format + + +class HWDeviceType(IntEnum): + none = lib.AV_HWDEVICE_TYPE_NONE + vdpau = lib.AV_HWDEVICE_TYPE_VDPAU + cuda = lib.AV_HWDEVICE_TYPE_CUDA + vaapi = lib.AV_HWDEVICE_TYPE_VAAPI + dxva2 = lib.AV_HWDEVICE_TYPE_DXVA2 + qsv = lib.AV_HWDEVICE_TYPE_QSV + videotoolbox = lib.AV_HWDEVICE_TYPE_VIDEOTOOLBOX + d3d11va = lib.AV_HWDEVICE_TYPE_D3D11VA + drm = lib.AV_HWDEVICE_TYPE_DRM + opencl = lib.AV_HWDEVICE_TYPE_OPENCL + mediacodec = lib.AV_HWDEVICE_TYPE_MEDIACODEC + vulkan = lib.AV_HWDEVICE_TYPE_VULKAN + d3d12va = lib.AV_HWDEVICE_TYPE_D3D12VA + amf = 13 # FFmpeg >=8 + ohcodec = 14 + # TODO: When ffmpeg major is changed, check this enum. + + +class HWConfigMethod(IntEnum): + none = 0 + hw_device_ctx = ( + lib.AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX + ) # This is the only one we support. + hw_frame_ctx = lib.AV_CODEC_HW_CONFIG_METHOD_HW_FRAMES_CTX + internal = lib.AV_CODEC_HW_CONFIG_METHOD_INTERNAL + ad_hoc = lib.AV_CODEC_HW_CONFIG_METHOD_AD_HOC + + +_cinit_sentinel = cython.declare(object, object()) +_singletons = cython.declare(object, weakref.WeakValueDictionary()) + + +@cython.cfunc +def wrap_hwconfig(ptr: cython.pointer[cython.const[lib.AVCodecHWConfig]]) -> HWConfig: + try: + return _singletons[cython.cast(cython.Py_ssize_t, ptr)] + except KeyError: + pass + config: HWConfig = HWConfig(_cinit_sentinel) + config._init(ptr) + _singletons[cython.cast(cython.Py_ssize_t, ptr)] = config + return config + + +@cython.final +@cython.cclass +class HWConfig: + def __init__(self, sentinel): + if sentinel is not _cinit_sentinel: + raise RuntimeError("Cannot instantiate CodecContext") + + @cython.cfunc + def _init( + self, ptr: cython.pointer[cython.const[lib.AVCodecHWConfig]] + ) -> cython.void: + self.ptr = ptr + + def __repr__(self): + return ( + f"" + ) + + @property + def device_type(self): + return HWDeviceType(self.ptr.device_type) + + @property + def format(self): + return get_video_format(self.ptr.pix_fmt, 0, 0) + + @property + def methods(self): + return HWConfigMethod(self.ptr.methods) + + @property + def is_supported(self): + return bool(self.ptr.methods & lib.AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) + + +@cython.ccall +def hwdevices_available(): + """Return the names of the hardware device types FFmpeg was built with, + e.g. ``["cuda", "videotoolbox"]``.""" + result: list = [] + x: lib.AVHWDeviceType = lib.AV_HWDEVICE_TYPE_NONE + while True: + x = lib.av_hwdevice_iterate_types(x) + if x == lib.AV_HWDEVICE_TYPE_NONE: + break + result.append(lib.av_hwdevice_get_type_name(HWDeviceType(x))) + return result + + +@cython.final +@cython.cclass +class HWAccel: + """HWAccel(device_type, device=None, allow_software_fallback=True, options=None, flags=None, is_hw_owned=False) + + Settings for hardware-accelerated decoding and encoding. Pass an instance to + :func:`av.open` to decode on the device, or to + :meth:`OutputContainer.add_stream ` + to encode on it. + + :param device_type: The kind of device, e.g. ``"cuda"``, ``"vaapi"``, + ``"videotoolbox"``. See :func:`hwdevices_available` for the types + supported by the loaded FFmpeg. + :type device_type: str or HWDeviceType + :param str device: An optional device identifier, e.g. a DRI render node path + (``"/dev/dri/renderD128"``) for VAAPI or a device index (``"1"``) for CUDA. + Uses the default device if ``None``. + :param bool allow_software_fallback: Whether decoding falls back to a software + decoder when the hardware decoder cannot handle the stream. + :param dict options: Options passed to ``av_hwdevice_ctx_create``. + :param int flags: Flags passed to ``av_hwdevice_ctx_create``. + :param bool is_hw_owned: If True, decoded frames stay on the device (for + consumption via DLPack, for example) instead of being downloaded to + system memory. + """ + + def __init__( + self, + device_type, + device=None, + allow_software_fallback=True, + options=None, + flags=None, + is_hw_owned=False, + ): + if isinstance(device_type, HWDeviceType): + self._device_type = device_type + elif isinstance(device_type, str): + self._device_type = int(lib.av_hwdevice_find_type_by_name(device_type)) + elif isinstance(device_type, int): + self._device_type = device_type + else: + raise ValueError("Unknown type for device_type") + + self.is_hw_owned = is_hw_owned + self.device_id = 0 + if self._device_type == HWDeviceType.cuda and device: + self.device_id = int(device) + + self._device = None if device is None else f"{device}" + self.allow_software_fallback = allow_software_fallback + + self.options = {} if not options else dict(options) + if self._device_type == HWDeviceType.cuda and self.is_hw_owned: + self.options.setdefault("primary_ctx", "1") + self.flags = 0 if not flags else flags + self.ptr = cython.NULL + self.config = None + + def _initialize_hw_context(self, codec: Codec, for_encoding: bool = False): + # Decoders advertise the device-context method, while encoders (e.g. + # h264_vaapi) advertise the frames-context method. Accept either one when + # setting up an encoder. + supported_methods: cython.int = lib.AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX + if for_encoding: + supported_methods |= lib.AV_CODEC_HW_CONFIG_METHOD_HW_FRAMES_CTX + + config: HWConfig + for config in codec.hardware_configs: + if not (config.ptr.methods & supported_methods): + continue + if self._device_type and config.device_type != self._device_type: + continue + break + else: # nobreak + raise NotImplementedError(f"No supported hardware config for {codec}") + + self.config = config + c_device: cython.p_char = cython.NULL + if self._device: + device_bytes = self._device.encode() + c_device = device_bytes + c_options: Dictionary = Dictionary(self.options) + + err_check( + lib.av_hwdevice_ctx_create( + cython.address(self.ptr), + config.ptr.device_type, + c_device, + c_options.ptr, + self.flags, + ) + ) + + def create(self, codec: Codec, for_encoding: bool = False) -> HWAccel: + """Create a new hardware accelerator context with the given codec""" + if self.ptr: + raise RuntimeError("Hardware context already initialized") + + ret = HWAccel( + device_type=self._device_type, + device=self._device, + allow_software_fallback=self.allow_software_fallback, + options=self.options, + is_hw_owned=self.is_hw_owned, + ) + ret._initialize_hw_context(codec, for_encoding=for_encoding) + return ret + + def __dealloc__(self): + if self.ptr: + lib.av_buffer_unref(cython.address(self.ptr)) diff --git a/av/codec/hwaccel.pyi b/av/codec/hwaccel.pyi new file mode 100644 index 000000000..b3b0c3ff0 --- /dev/null +++ b/av/codec/hwaccel.pyi @@ -0,0 +1,57 @@ +from enum import IntEnum +from typing import cast + +from av.codec.codec import Codec +from av.video.format import VideoFormat + +class HWDeviceType(IntEnum): + none = cast(int, ...) + vdpau = cast(int, ...) + cuda = cast(int, ...) + vaapi = cast(int, ...) + dxva2 = cast(int, ...) + qsv = cast(int, ...) + videotoolbox = cast(int, ...) + d3d11va = cast(int, ...) + drm = cast(int, ...) + opencl = cast(int, ...) + mediacodec = cast(int, ...) + vulkan = cast(int, ...) + d3d12va = cast(int, ...) + +class HWConfigMethod(IntEnum): + none = cast(int, ...) + hw_device_ctx = cast(int, ...) + hw_frame_ctx = cast(int, ...) + internal = cast(int, ...) + ad_hoc = cast(int, ...) + +class HWConfig: + @property + def device_type(self) -> HWDeviceType: ... + @property + def format(self) -> VideoFormat | None: ... + @property + def methods(self) -> HWConfigMethod: ... + @property + def is_supported(self) -> bool: ... + +class HWAccel: + options: dict[str, object] + + @property + def is_hw_owned(self) -> bool: ... + @property + def device_id(self) -> int: ... + def __init__( + self, + device_type: str | HWDeviceType, + device: str | int | None = None, + allow_software_fallback: bool = False, + options: dict[str, object] | None = None, + flags: int | None = None, + is_hw_owned: bool = False, + ) -> None: ... + def create(self, codec: Codec, for_encoding: bool = False) -> HWAccel: ... + +def hwdevices_available() -> list[str]: ... diff --git a/av/container/__init__.pxd b/av/container/__init__.pxd new file mode 100644 index 000000000..e69de29bb diff --git a/av/container/__init__.py b/av/container/__init__.py index 98d49dd4e..c5dde46ea 100644 --- a/av/container/__init__.py +++ b/av/container/__init__.py @@ -1,3 +1,3 @@ from .core import Container, Flags, open -from .input import InputContainer -from .output import OutputContainer +from .input import InputContainer as InputContainer +from .output import OutputContainer as OutputContainer diff --git a/av/container/__init__.pyi b/av/container/__init__.pyi new file mode 100644 index 000000000..7160777cc --- /dev/null +++ b/av/container/__init__.pyi @@ -0,0 +1,3 @@ +from .core import * +from .input import * +from .output import * diff --git a/av/container/core.pxd b/av/container/core.pxd index 01a9dfa0d..bd298a7a0 100644 --- a/av/container/core.pxd +++ b/av/container/core.pxd @@ -1,39 +1,29 @@ cimport libav as lib +from libc.stdint cimport uint8_t +from av.codec.hwaccel cimport HWAccel +from av.container.pyio cimport PyIOFile from av.container.streams cimport StreamContainer -from av.dictionary cimport _Dictionary +from av.dictionary cimport Dictionary from av.format cimport ContainerFormat from av.stream cimport Stream - # Interrupt callback information, times are in seconds. ctypedef struct timeout_info: double start_time double timeout -cdef class Container(object): - - cdef readonly bint writeable +cdef class Container: cdef lib.AVFormatContext *ptr - cdef readonly object name cdef readonly str metadata_encoding cdef readonly str metadata_errors - # File-like source. - cdef readonly object file - cdef object fread - cdef object fwrite - cdef object fseek - cdef object ftell - - # Custom IO for above. - cdef lib.AVIOContext *iocontext - cdef unsigned char *buffer - cdef long pos - cdef bint pos_is_valid - cdef bint input_was_opened + cdef readonly PyIOFile file + cdef int buffer_size + cdef readonly object io_open + cdef readonly object open_files cdef readonly ContainerFormat format @@ -41,9 +31,14 @@ cdef class Container(object): cdef readonly dict container_options cdef readonly list stream_options + cdef HWAccel hwaccel + cdef readonly StreamContainer streams - cdef readonly dict metadata + cdef dict _metadata + # Private API. + cdef uint8_t _myflag # enum: writeable, input_was_opened, started, done, extradata_planned + cdef _assert_open(self) cdef int err_check(self, int value) except -1 # Timeouts diff --git a/av/container/core.py b/av/container/core.py new file mode 100755 index 000000000..bd82e86a4 --- /dev/null +++ b/av/container/core.py @@ -0,0 +1,612 @@ +import os +import time +from enum import Flag, IntEnum +from pathlib import Path + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.container.core import timeout_info +from cython.cimports.av.container.input import InputContainer +from cython.cimports.av.container.output import OutputContainer +from cython.cimports.av.container.pyio import pyio_close_custom_gil, pyio_close_gil +from cython.cimports.av.error import err_check, stash_exception +from cython.cimports.av.format import build_container_format +from cython.cimports.av.utils import ( + avdict_to_dict, + avrational_to_fraction, + dict_to_avdict, + to_avrational, +) +from cython.cimports.libc.stdint import int64_t +from cython.operator import dereference + +from av.logging import Capture as LogCapture + +_cinit_sentinel = cython.declare(object, object()) + +AVChapterPtrPtr = cython.typedef(cython.pointer[cython.pointer[lib.AVChapter]]) + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def interrupt_cb(p: cython.p_void) -> cython.int: + info: timeout_info = dereference(cython.cast(cython.pointer[timeout_info], p)) + if info.timeout < 0: # timeout < 0 means no timeout + return 0 + + current_time: cython.double + with cython.gil: + current_time = time.monotonic() + if current_time < info.start_time: + # Raise this when we get back to Python. + stash_exception( + ( + RuntimeError, + RuntimeError("Clock has been changed to before timeout start"), + None, + ) + ) + return 1 + + if current_time > info.start_time + info.timeout: + return 1 + return 0 + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def pyav_io_open( + s: cython.pointer[lib.AVFormatContext], + pb: cython.pointer[cython.pointer[lib.AVIOContext]], + url: cython.p_const_char, + flags: cython.int, + options: cython.pointer[cython.pointer[lib.AVDictionary]], +) -> cython.int: + with cython.gil: + return pyav_io_open_gil(s, pb, url, flags, options) + + +@cython.cfunc +@cython.exceptval(check=False) +def pyav_io_open_gil( + s: cython.pointer[lib.AVFormatContext], + pb: cython.pointer[cython.pointer[lib.AVIOContext]], + url: cython.p_const_char, + flags: cython.int, + options: cython.pointer[cython.pointer[lib.AVDictionary]], +) -> cython.int: + container: Container + file: object + pyio_file: PyIOFile + try: + container = cython.cast(Container, dereference(s).opaque) + + if options is not cython.NULL: + options_dict = avdict_to_dict( + dereference( + cython.cast( + cython.pointer[cython.pointer[lib.AVDictionary]], options + ) + ), + encoding=container.metadata_encoding, + errors=container.metadata_errors, + ) + else: + options_dict = {} + + file = container.io_open( + cython.cast(str, url) if url is not cython.NULL else "", flags, options_dict + ) + + pyio_file = PyIOFile( + file, container.buffer_size, (flags & lib.AVIO_FLAG_WRITE) != 0 + ) + + # Add it to the container to avoid it being deallocated + container.open_files[cython.cast(int64_t, pyio_file.iocontext.opaque)] = ( + pyio_file + ) + pb[0] = pyio_file.iocontext + return 0 + except Exception: + return stash_exception() + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def pyav_io_close( + s: cython.pointer[lib.AVFormatContext], pb: cython.pointer[lib.AVIOContext] +) -> cython.int: + with cython.gil: + return pyav_io_close_gil(s, pb) + + +@cython.cfunc +@cython.exceptval(check=False) +def pyav_io_close_gil( + s: cython.pointer[lib.AVFormatContext], pb: cython.pointer[lib.AVIOContext] +) -> cython.int: + container: Container + result: cython.int = 0 + try: + container = cython.cast(Container, dereference(s).opaque) + if ( + container.open_files is not None + and cython.cast(int64_t, pb.opaque) in container.open_files + ): + result = pyio_close_custom_gil(pb) + + # Remove it from the container so that it can be deallocated + del container.open_files[cython.cast(int64_t, pb.opaque)] + else: + result = pyio_close_gil(pb) + + except Exception: + stash_exception() + result = lib.AVERROR_UNKNOWN # Or another appropriate error code + + return result + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def _free_chapters(ctx: cython.pointer[lib.AVFormatContext]) -> cython.void: + i: cython.Py_ssize_t + if ctx.chapters != cython.NULL: + for i in range(ctx.nb_chapters): + if ctx.chapters[i] != cython.NULL: + if ctx.chapters[i].metadata != cython.NULL: + lib.av_dict_free(cython.address(ctx.chapters[i].metadata)) + lib.av_freep( + cython.cast(cython.pp_void, cython.address(ctx.chapters[i])) + ) + lib.av_freep(cython.cast(cython.pp_void, cython.address(ctx.chapters))) + ctx.nb_chapters = 0 + + +# fmt: off +class Flags(Flag): + gen_pts: "Generate missing pts even if it requires parsing future frames." = lib.AVFMT_FLAG_GENPTS + ign_idx: "Ignore index." = lib.AVFMT_FLAG_IGNIDX + non_block: "Do not block when reading packets from input." = lib.AVFMT_FLAG_NONBLOCK + ign_dts: "Ignore DTS on frames that contain both DTS & PTS." = lib.AVFMT_FLAG_IGNDTS + no_fillin: "Do not infer any values from other values, just return what is stored in the container." = lib.AVFMT_FLAG_NOFILLIN + no_parse: "Do not use AVParsers, you also must set AVFMT_FLAG_NOFILLIN as the fill in code works on frames and no parsing -> no frames. Also seeking to frames can not work if parsing to find frame boundaries has been disabled." = lib.AVFMT_FLAG_NOPARSE + no_buffer: "Do not buffer frames when possible." = lib.AVFMT_FLAG_NOBUFFER + custom_io: "The caller has supplied a custom AVIOContext, don't avio_close() it." = lib.AVFMT_FLAG_CUSTOM_IO + discard_corrupt: "Discard frames marked corrupted." = lib.AVFMT_FLAG_DISCARD_CORRUPT + flush_packets: "Flush the AVIOContext every packet." = lib.AVFMT_FLAG_FLUSH_PACKETS + bitexact: "When muxing, try to avoid writing any random/volatile data to the output. This includes any random IDs, real-time timestamps/dates, muxer version, etc. This flag is mainly intended for testing." = lib.AVFMT_FLAG_BITEXACT + sort_dts: "Try to interleave outputted packets by dts (using this flag can slow demuxing down)." = lib.AVFMT_FLAG_SORT_DTS + fast_seek: "Enable fast, but inaccurate seeks for some formats." = lib.AVFMT_FLAG_FAST_SEEK + auto_bsf: "Add bitstream filters as requested by the muxer." = lib.AVFMT_FLAG_AUTO_BSF + +class AudioCodec(IntEnum): + """Enumeration for audio codec IDs.""" + none = lib.AV_CODEC_ID_NONE # No codec. + pcm_alaw = lib.AV_CODEC_ID_PCM_ALAW # PCM A-law. + pcm_bluray = lib.AV_CODEC_ID_PCM_BLURAY # PCM Blu-ray. + pcm_dvd = lib.AV_CODEC_ID_PCM_DVD # PCM DVD. + pcm_f16le = lib.AV_CODEC_ID_PCM_F16LE # PCM F16 little-endian. + pcm_f24le = lib.AV_CODEC_ID_PCM_F24LE # PCM F24 little-endian. + pcm_f32be = lib.AV_CODEC_ID_PCM_F32BE # PCM F32 big-endian. + pcm_f32le = lib.AV_CODEC_ID_PCM_F32LE # PCM F32 little-endian. + pcm_f64be = lib.AV_CODEC_ID_PCM_F64BE # PCM F64 big-endian. + pcm_f64le = lib.AV_CODEC_ID_PCM_F64LE # PCM F64 little-endian. + pcm_lxf = lib.AV_CODEC_ID_PCM_LXF # PCM LXF. + pcm_mulaw = lib.AV_CODEC_ID_PCM_MULAW # PCM μ-law. + pcm_s16be = lib.AV_CODEC_ID_PCM_S16BE # PCM signed 16-bit big-endian. + pcm_s16be_planar = lib.AV_CODEC_ID_PCM_S16BE_PLANAR # PCM signed 16-bit big-endian planar. + pcm_s16le = lib.AV_CODEC_ID_PCM_S16LE # PCM signed 16-bit little-endian. + pcm_s16le_planar = lib.AV_CODEC_ID_PCM_S16LE_PLANAR # PCM signed 16-bit little-endian planar. + pcm_s24be = lib.AV_CODEC_ID_PCM_S24BE # PCM signed 24-bit big-endian. + pcm_s24daud = lib.AV_CODEC_ID_PCM_S24DAUD # PCM signed 24-bit D-Cinema audio. + pcm_s24le = lib.AV_CODEC_ID_PCM_S24LE # PCM signed 24-bit little-endian. + pcm_s24le_planar = lib.AV_CODEC_ID_PCM_S24LE_PLANAR # PCM signed 24-bit little-endian planar. + pcm_s32be = lib.AV_CODEC_ID_PCM_S32BE # PCM signed 32-bit big-endian. + pcm_s32le = lib.AV_CODEC_ID_PCM_S32LE # PCM signed 32-bit little-endian. + pcm_s32le_planar = lib.AV_CODEC_ID_PCM_S32LE_PLANAR # PCM signed 32-bit little-endian planar. + pcm_s64be = lib.AV_CODEC_ID_PCM_S64BE # PCM signed 64-bit big-endian. + pcm_s64le = lib.AV_CODEC_ID_PCM_S64LE # PCM signed 64-bit little-endian. + pcm_s8 = lib.AV_CODEC_ID_PCM_S8 # PCM signed 8-bit. + pcm_s8_planar = lib.AV_CODEC_ID_PCM_S8_PLANAR # PCM signed 8-bit planar. + pcm_u16be = lib.AV_CODEC_ID_PCM_U16BE # PCM unsigned 16-bit big-endian. + pcm_u16le = lib.AV_CODEC_ID_PCM_U16LE # PCM unsigned 16-bit little-endian. + pcm_u24be = lib.AV_CODEC_ID_PCM_U24BE # PCM unsigned 24-bit big-endian. + pcm_u24le = lib.AV_CODEC_ID_PCM_U24LE # PCM unsigned 24-bit little-endian. + pcm_u32be = lib.AV_CODEC_ID_PCM_U32BE # PCM unsigned 32-bit big-endian. + pcm_u32le = lib.AV_CODEC_ID_PCM_U32LE # PCM unsigned 32-bit little-endian. + pcm_u8 = lib.AV_CODEC_ID_PCM_U8 # PCM unsigned 8-bit. + pcm_vidc = lib.AV_CODEC_ID_PCM_VIDC # PCM VIDC. +# fmt: on + + +@cython.cclass +class Container: + def __cinit__( + self, + sentinel, + file_, + format_name, + options, + container_options, + stream_options, + hwaccel, + metadata_encoding, + metadata_errors, + buffer_size, + open_timeout, + read_timeout, + io_open, + ): + if sentinel is not _cinit_sentinel: + raise RuntimeError("cannot construct base Container") + + writeable: cython.bint = isinstance(self, OutputContainer) + if not writeable and not isinstance(self, InputContainer): + raise RuntimeError("Container cannot be directly extended.") + + if isinstance(file_, str): + self.name = file_ + else: + self.name = str(getattr(file_, "name", "")) + + self.options = dict(options or ()) + self.container_options = dict(container_options or ()) + self.stream_options = [dict(x) for x in stream_options or ()] + + self.hwaccel = hwaccel + + self.metadata_encoding = metadata_encoding + self.metadata_errors = metadata_errors + + self.open_timeout = open_timeout + self.read_timeout = read_timeout + + self.buffer_size = buffer_size + self.io_open = io_open + + acodec = None # no audio codec specified + if format_name is not None: + if ":" in format_name: + format_name, acodec = format_name.split(":") + self.format = ContainerFormat(format_name) + + res: cython.int + name_obj: bytes = os.fsencode(self.name) + name: cython.p_char = name_obj + ofmt: cython.pointer[cython.const[lib.AVOutputFormat]] + + if writeable: + self._myflag |= 1 # enum.writeable = True + ofmt = ( + self.format.optr + if self.format + else lib.av_guess_format(cython.NULL, name, cython.NULL) + ) + if ofmt == cython.NULL: + raise ValueError("Could not determine output format") + + with cython.nogil: + # This does not actually open the file. + res = lib.avformat_alloc_output_context2( + cython.address(self.ptr), + ofmt, + cython.NULL, + name, + ) + self.err_check(res) + else: + # We need the context before we open the input AND setup Python IO. + self.ptr = lib.avformat_alloc_context() + + # Setup interrupt callback + if self.open_timeout is not None or self.read_timeout is not None: + self.ptr.interrupt_callback.callback = interrupt_cb + self.ptr.interrupt_callback.opaque = cython.address( + self.interrupt_callback_info + ) + + if acodec is not None: + self.ptr.audio_codec_id = getattr(AudioCodec, acodec) + + self.ptr.flags |= lib.AVFMT_FLAG_GENPTS + self.ptr.opaque = cython.cast(cython.p_void, self) + + # Setup Python IO. + self.open_files = {} + if not isinstance(file_, basestring): + self.file = PyIOFile(file_, buffer_size, writeable) + self.ptr.pb = self.file.iocontext + + if io_open is not None: + self.ptr.io_open = pyav_io_open + self.ptr.io_close2 = pyav_io_close + self.ptr.flags |= lib.AVFMT_FLAG_CUSTOM_IO + + ifmt: cython.pointer[cython.const[lib.AVInputFormat]] + c_options: Dictionary + if not writeable: + ifmt = self.format.iptr if self.format else cython.NULL + c_options = Dictionary(self.options, self.container_options) + + self.set_timeout(self.open_timeout) + self.start_timeout() + with cython.nogil: + res = lib.avformat_open_input( + cython.address(self.ptr), name, ifmt, cython.address(c_options.ptr) + ) + self.set_timeout(None) + self.err_check(res) + self._myflag |= 2 # enum.input_was_opened = True + + if format_name is None: + self.format = build_container_format(self.ptr.iformat, self.ptr.oformat) + + def __dealloc__(self): + with cython.nogil: + lib.avformat_free_context(self.ptr) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self): + raise NotImplementedError + + def __repr__(self): + return f"" + + @cython.cfunc + @cython.exceptval(-1, check=False) + def err_check(self, value: cython.int) -> cython.int: + return err_check(value, filename=self.name) + + def dumps_format(self): + self._assert_open() + with LogCapture() as logs: + lib.av_dump_format(self.ptr, 0, "", isinstance(self, OutputContainer)) + return "".join(log[2] for log in logs) + + @cython.cfunc + def set_timeout(self, timeout): + if timeout is None: + self.interrupt_callback_info.timeout = -1.0 + else: + self.interrupt_callback_info.timeout = timeout + + @cython.cfunc + def start_timeout(self): + self.interrupt_callback_info.start_time = time.monotonic() + + @cython.cfunc + def _assert_open(self): + if self.ptr == cython.NULL: + raise AssertionError("Container is not open") + + @property + def video_codec_id(self): + self._assert_open() + return self.ptr.video_codec_id + + @video_codec_id.setter + def video_codec_id(self, value: lib.AVCodecID): + self._assert_open() + self.ptr.video_codec_id = value + + @property + def flags(self): + self._assert_open() + return self.ptr.flags + + @flags.setter + def flags(self, value: cython.int): + self._assert_open() + self.ptr.flags = value + + @property + def input_was_opened(self): + return self._myflag & 2 + + @property + def metadata(self) -> dict: + # Lazily created so output containers that never touch metadata don't + # allocate a dict. Input containers populate ``_metadata`` eagerly. + if self._metadata is None: + self._metadata = {} + return self._metadata + + def chapters(self): + self._assert_open() + result: list = [] + i: cython.Py_ssize_t + for i in range(self.ptr.nb_chapters): + ch = self.ptr.chapters[i] + result.append( + { + "id": ch.id, + "start": ch.start, + "end": ch.end, + "time_base": avrational_to_fraction(cython.address(ch.time_base)), + "metadata": avdict_to_dict( + ch.metadata, self.metadata_encoding, self.metadata_errors + ), + } + ) + return result + + def set_chapters(self, chapters): + self._assert_open() + + count: cython.Py_ssize_t = len(chapters) + i: cython.Py_ssize_t + ch_array: AVChapterPtrPtr + ch: cython.pointer[lib.AVChapter] + entry: dict + + with cython.nogil: + _free_chapters(self.ptr) + + if count == 0: + self.ptr.nb_chapters = 0 + self.ptr.chapters = cython.NULL + return + + ch_array = cython.cast( + AVChapterPtrPtr, + lib.av_malloc(count * cython.sizeof(cython.pointer[lib.AVChapter])), + ) + if ch_array == cython.NULL: + raise MemoryError("av_malloc failed for chapters") + + for i in range(count): + entry = chapters[i] + ch = cython.cast( + cython.pointer[lib.AVChapter], + lib.av_malloc(cython.sizeof(lib.AVChapter)), + ) + if ch == cython.NULL: + raise MemoryError("av_malloc failed for chapter") + ch.id = entry["id"] + ch.start = cython.cast(int64_t, entry["start"]) + ch.end = cython.cast(int64_t, entry["end"]) + to_avrational(entry["time_base"], cython.address(ch.time_base)) + ch.metadata = cython.NULL + if "metadata" in entry: + dict_to_avdict( + cython.address(ch.metadata), + entry["metadata"], + self.metadata_encoding, + self.metadata_errors, + ) + ch_array[i] = ch + + self.ptr.nb_chapters = cython.cast(cython.uint, count) + self.ptr.chapters = ch_array + + +def open( + file, + mode=None, + format=None, + options=None, + container_options=None, + stream_options=None, + metadata_encoding="utf-8", + metadata_errors="strict", + buffer_size=32768, + timeout=None, + io_open=None, + hwaccel=None, +): + """open(file, mode='r', **kwargs) + + Main entrypoint to opening files/streams. + + :param str file: The file to open, which can be either a string or a file-like object. + :param str mode: ``"r"`` for reading and ``"w"`` for writing. + :param str format: Specific format to use. Defaults to autodect. + :param dict options: Options to pass to the container and all streams. + :param dict container_options: Options to pass to the container. + :param list stream_options: Options to pass to each stream. + :param str metadata_encoding: Encoding to use when reading or writing file metadata. + Defaults to ``"utf-8"``. + :param str metadata_errors: Specifies how to handle encoding errors; behaves like + ``str.encode`` parameter. Defaults to ``"strict"``. + :param int buffer_size: Size of buffer for Python input/output operations in bytes. + Honored only when ``file`` is a file-like object. Defaults to 32768 (32k). + :param timeout: How many seconds to wait for data before giving up, as a float, or a + ``(open timeout, read timeout)`` tuple. + :param callable io_open: Custom I/O callable for opening files/streams. + This option is intended for formats that need to open additional + file-like objects to ``file`` using custom I/O. + The callable signature is ``io_open(url: str, flags: int, options: dict)``, where + ``url`` is the url to open, ``flags`` is a combination of AVIO_FLAG_* and + ``options`` is a dictionary of additional options. The callable should return a + file-like object. + :param HWAccel hwaccel: Optional settings for hardware-accelerated decoding. + :rtype: Container + + For devices (via ``libavdevice``), pass the name of the device to ``format``, + e.g.:: + + >>> # Open webcam on MacOS. + >>> av.open('0', format='avfoundation') # doctest: +SKIP + + For DASH and custom I/O using ``io_open``, add a protocol prefix to the ``file`` to + prevent the DASH encoder defaulting to the file protocol and using temporary files. + The custom I/O callable can be used to remove the protocol prefix to reveal the actual + name for creating the file-like object. E.g.:: + + >>> av.open("customprotocol://manifest.mpd", "w", io_open=custom_io) # doctest: +SKIP + + .. seealso:: :ref:`garbage_collection` + + More information on using input and output devices is available on the + `FFmpeg website `_. + """ + + if not (mode is None or (isinstance(mode, str) and mode == "r" or mode == "w")): + raise ValueError(f"mode must be 'r', 'w', or None, got: {mode}") + + if isinstance(file, str): + pass + elif isinstance(file, Path): + file = f"{file}" + elif mode is None: + mode = getattr(file, "mode", None) + + if mode is None: + mode = "r" + + if isinstance(timeout, tuple): + if not len(timeout) == 2: + raise ValueError("timeout must be `float` or `tuple[float, float]`") + + open_timeout, read_timeout = timeout + else: + open_timeout = timeout + read_timeout = timeout + + if mode.startswith("r"): + return InputContainer( + _cinit_sentinel, + file, + format, + options, + container_options, + stream_options, + hwaccel, + metadata_encoding, + metadata_errors, + buffer_size, + open_timeout, + read_timeout, + io_open, + ) + + if stream_options: + raise ValueError( + "Provide stream options via Container.add_stream(..., options={})." + ) + return OutputContainer( + _cinit_sentinel, + file, + format, + options, + container_options, + stream_options, + None, + metadata_encoding, + metadata_errors, + buffer_size, + open_timeout, + read_timeout, + io_open, + ) diff --git a/av/container/core.pyi b/av/container/core.pyi new file mode 100644 index 000000000..ec4a84d89 --- /dev/null +++ b/av/container/core.pyi @@ -0,0 +1,168 @@ +from collections.abc import Callable +from enum import Flag, IntEnum +from fractions import Fraction +from pathlib import Path +from types import TracebackType +from typing import Any, ClassVar, Literal, Self, TypedDict, cast, overload + +from av.codec.hwaccel import HWAccel +from av.format import ContainerFormat + +from .input import InputContainer +from .output import OutputContainer +from .streams import StreamContainer + +Real = int | float | Fraction + +class Flags(Flag): + gen_pts = cast(ClassVar[Flags], ...) + ign_idx = cast(ClassVar[Flags], ...) + non_block = cast(ClassVar[Flags], ...) + ign_dts = cast(ClassVar[Flags], ...) + no_fillin = cast(ClassVar[Flags], ...) + no_parse = cast(ClassVar[Flags], ...) + no_buffer = cast(ClassVar[Flags], ...) + custom_io = cast(ClassVar[Flags], ...) + discard_corrupt = cast(ClassVar[Flags], ...) + flush_packets = cast(ClassVar[Flags], ...) + bitexact = cast(ClassVar[Flags], ...) + sort_dts = cast(ClassVar[Flags], ...) + fast_seek = cast(ClassVar[Flags], ...) + shortest = cast(ClassVar[Flags], ...) + auto_bsf = cast(ClassVar[Flags], ...) + +class AudioCodec(IntEnum): + none = cast(int, ...) + pcm_alaw = cast(int, ...) + pcm_bluray = cast(int, ...) + pcm_dvd = cast(int, ...) + pcm_f16le = cast(int, ...) + pcm_f24le = cast(int, ...) + pcm_f32be = cast(int, ...) + pcm_f32le = cast(int, ...) + pcm_f64be = cast(int, ...) + pcm_f64le = cast(int, ...) + pcm_lxf = cast(int, ...) + pcm_mulaw = cast(int, ...) + pcm_s16be = cast(int, ...) + pcm_s16be_planar = cast(int, ...) + pcm_s16le = cast(int, ...) + pcm_s16le_planar = cast(int, ...) + pcm_s24be = cast(int, ...) + pcm_s24daud = cast(int, ...) + pcm_s24le = cast(int, ...) + pcm_s24le_planar = cast(int, ...) + pcm_s32be = cast(int, ...) + pcm_s32le = cast(int, ...) + pcm_s32le_planar = cast(int, ...) + pcm_s64be = cast(int, ...) + pcm_s64le = cast(int, ...) + pcm_s8 = cast(int, ...) + pcm_s8_planar = cast(int, ...) + pcm_u16be = cast(int, ...) + pcm_u16le = cast(int, ...) + pcm_u24be = cast(int, ...) + pcm_u24le = cast(int, ...) + pcm_u32be = cast(int, ...) + pcm_u32le = cast(int, ...) + pcm_u8 = cast(int, ...) + pcm_vidc = cast(int, ...) + +class Chapter(TypedDict): + id: int + start: int + end: int + time_base: Fraction | None + metadata: dict[str, str] + +class Container: + name: str + metadata_encoding: str + metadata_errors: str + file: Any + buffer_size: int + io_open: Any + open_files: Any + format: ContainerFormat + options: dict[str, str] + container_options: dict[str, str] + stream_options: list[dict[str, str]] + streams: StreamContainer + metadata: dict[str, str] + open_timeout: Real | None + read_timeout: Real | None + flags: int + video_codec_id: int + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: ... + @property + def input_was_opened(self) -> bool: ... + def close(self) -> None: ... + def chapters(self) -> list[Chapter]: ... + def set_chapters(self, chapters: list[Chapter]) -> None: ... + +@overload +def open( + file: Any, + mode: Literal["r"], + format: str | None = None, + options: dict[str, str] | None = None, + container_options: dict[str, str] | None = None, + stream_options: list[str] | None = None, + metadata_encoding: str = "utf-8", + metadata_errors: str = "strict", + buffer_size: int = 32768, + timeout: Real | None | tuple[Real | None, Real | None] = None, + io_open: Callable[..., Any] | None = None, + hwaccel: HWAccel | None = None, +) -> InputContainer: ... +@overload +def open( + file: str | Path, + mode: Literal["r"] | None = None, + format: str | None = None, + options: dict[str, str] | None = None, + container_options: dict[str, str] | None = None, + stream_options: list[str] | None = None, + metadata_encoding: str = "utf-8", + metadata_errors: str = "strict", + buffer_size: int = 32768, + timeout: Real | None | tuple[Real | None, Real | None] = None, + io_open: Callable[..., Any] | None = None, + hwaccel: HWAccel | None = None, +) -> InputContainer: ... +@overload +def open( + file: Any, + mode: Literal["w"], + format: str | None = None, + options: dict[str, str] | None = None, + container_options: dict[str, str] | None = None, + stream_options: list[str] | None = None, + metadata_encoding: str = "utf-8", + metadata_errors: str = "strict", + buffer_size: int = 32768, + timeout: Real | None | tuple[Real | None, Real | None] = None, + io_open: Callable[..., Any] | None = None, + hwaccel: HWAccel | None = None, +) -> OutputContainer: ... +@overload +def open( + file: Any, + mode: Literal["r", "w"] | None = None, + format: str | None = None, + options: dict[str, str] | None = None, + container_options: dict[str, str] | None = None, + stream_options: list[str] | None = None, + metadata_encoding: str = "utf-8", + metadata_errors: str = "strict", + buffer_size: int = 32768, + timeout: Real | None | tuple[Real | None, Real | None] = None, + io_open: Callable[..., Any] | None = None, + hwaccel: HWAccel | None = None, +) -> InputContainer | OutputContainer: ... diff --git a/av/container/core.pyx b/av/container/core.pyx deleted file mode 100755 index afbe2f6f6..000000000 --- a/av/container/core.pyx +++ /dev/null @@ -1,370 +0,0 @@ -from cython.operator cimport dereference -from libc.stdint cimport int64_t -from libc.stdlib cimport free, malloc - -import os -import time - -cimport libav as lib - -from av.container.core cimport timeout_info -from av.container.input cimport InputContainer -from av.container.output cimport OutputContainer -from av.container.pyio cimport pyio_read, pyio_seek, pyio_write -from av.enum cimport define_enum -from av.error cimport err_check, stash_exception -from av.format cimport build_container_format - -from av.dictionary import Dictionary -from av.logging import Capture as LogCapture - - -ctypedef int64_t (*seek_func_t)(void *opaque, int64_t offset, int whence) nogil - - -cdef object _cinit_sentinel = object() - - -# We want to use the monotonic clock if it is available. -cdef object clock = getattr(time, 'monotonic', time.time) - -cdef int interrupt_cb (void *p) nogil: - - cdef timeout_info info = dereference( p) - if info.timeout < 0: # timeout < 0 means no timeout - return 0 - - cdef double current_time - with gil: - - current_time = clock() - - # Check if the clock has been changed. - if current_time < info.start_time: - # Raise this when we get back to Python. - stash_exception((RuntimeError, RuntimeError("Clock has been changed to before timeout start"), None)) - return 1 - - if current_time > info.start_time + info.timeout: - return 1 - - return 0 - - -Flags = define_enum('Flags', __name__, ( - ('GENPTS', lib.AVFMT_FLAG_GENPTS, - "Generate missing pts even if it requires parsing future frames."), - ('IGNIDX', lib.AVFMT_FLAG_IGNIDX, - "Ignore index."), - ('NONBLOCK', lib.AVFMT_FLAG_NONBLOCK, - "Do not block when reading packets from input."), - ('IGNDTS', lib.AVFMT_FLAG_IGNDTS, - "Ignore DTS on frames that contain both DTS & PTS."), - ('NOFILLIN', lib.AVFMT_FLAG_NOFILLIN, - "Do not infer any values from other values, just return what is stored in the container."), - ('NOPARSE', lib.AVFMT_FLAG_NOPARSE, - """Do not use AVParsers, you also must set AVFMT_FLAG_NOFILLIN as the fillin code works on frames and no parsing -> no frames. - - Also seeking to frames can not work if parsing to find frame boundaries has been disabled."""), - ('NOBUFFER', lib.AVFMT_FLAG_NOBUFFER, - "Do not buffer frames when possible."), - ('CUSTOM_IO', lib.AVFMT_FLAG_CUSTOM_IO, - "The caller has supplied a custom AVIOContext, don't avio_close() it."), - ('DISCARD_CORRUPT', lib.AVFMT_FLAG_DISCARD_CORRUPT, - "Discard frames marked corrupted."), - ('FLUSH_PACKETS', lib.AVFMT_FLAG_FLUSH_PACKETS, - "Flush the AVIOContext every packet."), - ('BITEXACT', lib.AVFMT_FLAG_BITEXACT, - """When muxing, try to avoid writing any random/volatile data to the output. - - This includes any random IDs, real-time timestamps/dates, muxer version, etc. - This flag is mainly intended for testing."""), - ('MP4A_LATM', lib.AVFMT_FLAG_MP4A_LATM, - "Enable RTP MP4A-LATM payload"), - ('SORT_DTS', lib.AVFMT_FLAG_SORT_DTS, - "Try to interleave outputted packets by dts (using this flag can slow demuxing down)."), - ('PRIV_OPT', lib.AVFMT_FLAG_PRIV_OPT, - "Enable use of private options by delaying codec open (this could be made default once all code is converted)."), - ('KEEP_SIDE_DATA', lib.AVFMT_FLAG_KEEP_SIDE_DATA, - "Deprecated, does nothing."), - ('FAST_SEEK', lib.AVFMT_FLAG_FAST_SEEK, - "Enable fast, but inaccurate seeks for some formats."), - ('SHORTEST', lib.AVFMT_FLAG_SHORTEST, - "Stop muxing when the shortest stream stops."), - ('AUTO_BSF', lib.AVFMT_FLAG_AUTO_BSF, - "Add bitstream filters as requested by the muxer."), -), is_flags=True) - - -cdef class Container(object): - - def __cinit__(self, sentinel, file_, format_name, options, - container_options, stream_options, - metadata_encoding, metadata_errors, - buffer_size, open_timeout, read_timeout): - - if sentinel is not _cinit_sentinel: - raise RuntimeError('cannot construct base Container') - - self.writeable = isinstance(self, OutputContainer) - if not self.writeable and not isinstance(self, InputContainer): - raise RuntimeError('Container cannot be directly extended.') - - if isinstance(file_, str): - self.name = file_ - else: - self.name = getattr(file_, 'name', '') - if not isinstance(self.name, str): - raise TypeError("File's name attribute must be string-like.") - self.file = file_ - - self.options = dict(options or ()) - self.container_options = dict(container_options or ()) - self.stream_options = [dict(x) for x in stream_options or ()] - - self.metadata_encoding = metadata_encoding - self.metadata_errors = metadata_errors - - self.open_timeout = open_timeout - self.read_timeout = read_timeout - - if format_name is not None: - self.format = ContainerFormat(format_name) - - self.input_was_opened = False - cdef int res - - cdef bytes name_obj = os.fsencode(self.name) - cdef char *name = name_obj - cdef seek_func_t seek_func = NULL - - cdef lib.AVOutputFormat *ofmt - if self.writeable: - - ofmt = self.format.optr if self.format else lib.av_guess_format(NULL, name, NULL) - if ofmt == NULL: - raise ValueError("Could not determine output format") - - with nogil: - # This does not actually open the file. - res = lib.avformat_alloc_output_context2( - &self.ptr, - ofmt, - NULL, - name, - ) - self.err_check(res) - - else: - # We need the context before we open the input AND setup Python IO. - self.ptr = lib.avformat_alloc_context() - - # Setup interrupt callback - if self.open_timeout is not None or self.read_timeout is not None: - self.ptr.interrupt_callback.callback = interrupt_cb - self.ptr.interrupt_callback.opaque = &self.interrupt_callback_info - - self.ptr.flags |= lib.AVFMT_FLAG_GENPTS - self.ptr.max_analyze_duration = 10000000 - - # Setup Python IO. - if self.file is not None: - - self.fread = getattr(self.file, 'read', None) - self.fwrite = getattr(self.file, 'write', None) - self.fseek = getattr(self.file, 'seek', None) - self.ftell = getattr(self.file, 'tell', None) - - if self.writeable: - if self.fwrite is None: - raise ValueError("File object has no write method.") - else: - if self.fread is None: - raise ValueError("File object has no read method.") - - if self.fseek is not None and self.ftell is not None: - seek_func = pyio_seek - - self.pos = 0 - self.pos_is_valid = True - - # This is effectively the maximum size of reads. - self.buffer = lib.av_malloc(buffer_size) - - self.iocontext = lib.avio_alloc_context( - self.buffer, buffer_size, - self.writeable, # Writeable. - self, # User data. - pyio_read, - pyio_write, - seek_func - ) - - if seek_func: - self.iocontext.seekable = lib.AVIO_SEEKABLE_NORMAL - self.iocontext.max_packet_size = buffer_size - self.ptr.pb = self.iocontext - - cdef lib.AVInputFormat *ifmt - cdef _Dictionary c_options - if not self.writeable: - - ifmt = self.format.iptr if self.format else NULL - - c_options = Dictionary(self.options, self.container_options) - - self.set_timeout(self.open_timeout) - self.start_timeout() - with nogil: - res = lib.avformat_open_input( - &self.ptr, - name, - ifmt, - &c_options.ptr - ) - self.set_timeout(None) - self.err_check(res) - self.input_was_opened = True - - if format_name is None: - self.format = build_container_format(self.ptr.iformat, self.ptr.oformat) - - def __dealloc__(self): - with nogil: - # FFmpeg will not release custom input, so it's up to us to free it. - # Do not touch our original buffer as it may have been freed and replaced. - if self.iocontext: - lib.av_freep(&self.iocontext.buffer) - lib.av_freep(&self.iocontext) - - # We likely errored badly if we got here, and so are still - # responsible for our buffer. - else: - lib.av_freep(&self.buffer) - - # Finish releasing the whole structure. - lib.avformat_free_context(self.ptr) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - def __repr__(self): - return '' % (self.__class__.__name__, self.file or self.name) - - cdef int err_check(self, int value) except -1: - return err_check(value, filename=self.name) - - def dumps_format(self): - with LogCapture() as logs: - lib.av_dump_format(self.ptr, 0, "", isinstance(self, OutputContainer)) - return ''.join(log[2] for log in logs) - - cdef set_timeout(self, timeout): - if timeout is None: - self.interrupt_callback_info.timeout = -1.0 - else: - self.interrupt_callback_info.timeout = timeout - - cdef start_timeout(self): - self.interrupt_callback_info.start_time = clock() - - def _get_flags(self): - return self.ptr.flags - - def _set_flags(self, value): - self.ptr.flags = value - - flags = Flags.property( - _get_flags, - _set_flags, - """Flags property of :class:`.Flags`""" - ) - - gen_pts = flags.flag_property('GENPTS') - ign_idx = flags.flag_property('IGNIDX') - non_block = flags.flag_property('NONBLOCK') - ign_dts = flags.flag_property('IGNDTS') - no_fill_in = flags.flag_property('NOFILLIN') - no_parse = flags.flag_property('NOPARSE') - no_buffer = flags.flag_property('NOBUFFER') - custom_io = flags.flag_property('CUSTOM_IO') - discard_corrupt = flags.flag_property('DISCARD_CORRUPT') - flush_packets = flags.flag_property('FLUSH_PACKETS') - bit_exact = flags.flag_property('BITEXACT') - mp4a_latm = flags.flag_property('MP4A_LATM') - sort_dts = flags.flag_property('SORT_DTS') - priv_opt = flags.flag_property('PRIV_OPT') - keep_side_data = flags.flag_property('KEEP_SIDE_DATA') - fast_seek = flags.flag_property('FAST_SEEK') - shortest = flags.flag_property('SHORTEST') - auto_bsf = flags.flag_property('AUTO_BSF') - - -def open(file, mode=None, format=None, options=None, - container_options=None, stream_options=None, - metadata_encoding='utf-8', metadata_errors='strict', - buffer_size=32768, timeout=None): - """open(file, mode='r', **kwargs) - - Main entrypoint to opening files/streams. - - :param str file: The file to open, which can be either a string or a file-like object. - :param str mode: ``"r"`` for reading and ``"w"`` for writing. - :param str format: Specific format to use. Defaults to autodect. - :param dict options: Options to pass to the container and all streams. - :param dict container_options: Options to pass to the container. - :param list stream_options: Options to pass to each stream. - :param str metadata_encoding: Encoding to use when reading or writing file metadata. - Defaults to ``"utf-8"``. - :param str metadata_errors: Specifies how to handle encoding errors; behaves like - ``str.encode`` parameter. Defaults to ``"strict"``. - :param int buffer_size: Size of buffer for Python input/output operations in bytes. - Honored only when ``file`` is a file-like object. Defaults to 32768 (32k). - :param timeout: How many seconds to wait for data before giving up, as a float, or a - :ref:`(open timeout, read timeout) ` tuple. - :type timeout: float or tuple - - For devices (via ``libavdevice``), pass the name of the device to ``format``, - e.g.:: - - >>> # Open webcam on OS X. - >>> av.open(format='avfoundation', file='0') # doctest: +SKIP - - .. seealso:: :ref:`garbage_collection` - - More information on using input and output devices is available on the - `FFmpeg website `_. - """ - - if mode is None: - mode = getattr(file, 'mode', None) - if mode is None: - mode = 'r' - - if isinstance(timeout, tuple): - open_timeout = timeout[0] - read_timeout = timeout[1] - else: - open_timeout = timeout - read_timeout = timeout - - if mode.startswith('r'): - return InputContainer( - _cinit_sentinel, file, format, options, - container_options, stream_options, - metadata_encoding, metadata_errors, - buffer_size, open_timeout, read_timeout - ) - if mode.startswith('w'): - if stream_options: - raise ValueError("Provide stream options via Container.add_stream(..., options={}).") - return OutputContainer( - _cinit_sentinel, file, format, options, - container_options, stream_options, - metadata_encoding, metadata_errors, - buffer_size, open_timeout, read_timeout - ) - raise ValueError("mode must be 'r' or 'w'; got %r" % mode) diff --git a/av/container/input.pxd b/av/container/input.pxd index 8c369d8ad..2e65025b9 100644 --- a/av/container/input.pxd +++ b/av/container/input.pxd @@ -5,5 +5,4 @@ from av.stream cimport Stream cdef class InputContainer(Container): - cdef flush_buffers(self) diff --git a/av/container/input.py b/av/container/input.py new file mode 100644 index 000000000..18558126c --- /dev/null +++ b/av/container/input.py @@ -0,0 +1,317 @@ +import cython +from cython.cimports.av.codec.context import CodecContext, wrap_codec_context +from cython.cimports.av.container.streams import StreamContainer +from cython.cimports.av.dictionary import Dictionary +from cython.cimports.av.error import err_check +from cython.cimports.av.packet import Packet +from cython.cimports.av.stream import Stream, wrap_stream +from cython.cimports.av.utils import avdict_to_dict +from cython.cimports.libc.stdint import int64_t, uint8_t +from cython.cimports.libc.stdlib import free, malloc + + +@cython.cfunc +def close_input(self: InputContainer): + self.streams = StreamContainer() + with cython.nogil: + if self._myflag & 2: + # This causes `self.ptr` to be set to NULL. + lib.avformat_close_input(cython.address(self.ptr)) + self._myflag &= ~2 # enum.input_was_opened = False + + +@cython.final +@cython.cclass +class InputContainer(Container): + def __cinit__(self, *args, **kwargs): + py_codec_context: CodecContext + i: cython.uint + stream: cython.pointer[lib.AVStream] + codec: cython.pointer[cython.const[lib.AVCodec]] + codec_context: cython.pointer[lib.AVCodecContext] + + # If we have either the global `options`, or a `stream_options`, prepare + # a mashup of those options for each stream. + c_options: cython.pointer[cython.pointer[lib.AVDictionary]] = cython.NULL + base_dict: Dictionary + stream_dict: Dictionary + nb_streams_before: cython.uint = self.ptr.nb_streams + if self.stream_options and nb_streams_before == 0: + raise ValueError( + "stream_options were provided, but this format does not expose " + "its streams before avformat_find_stream_info (e.g. MPEG). " + "Per-stream options cannot be applied." + ) + # Only allocate c_options when streams are already known. + if (self.options or self.stream_options) and nb_streams_before > 0: + base_dict = Dictionary(self.options) + c_options = cython.cast( + cython.pointer[cython.pointer[lib.AVDictionary]], + malloc(nb_streams_before * cython.sizeof(cython.p_void)), + ) + for i in range(nb_streams_before): + c_options[i] = cython.NULL + if i < len(self.stream_options) and self.stream_options: + stream_dict = base_dict.copy() + stream_dict.update(self.stream_options[i]) + lib.av_dict_copy(cython.address(c_options[i]), stream_dict.ptr, 0) + else: + lib.av_dict_copy(cython.address(c_options[i]), base_dict.ptr, 0) + + self.set_timeout(self.open_timeout) + self.start_timeout() + with cython.nogil: + ret = lib.avformat_find_stream_info(self.ptr, c_options) + self.set_timeout(None) + self.err_check(ret) + + if c_options: + for i in range(nb_streams_before): + lib.av_dict_free(cython.address(c_options[i])) + free(c_options) + + at_least_one_accelerated_context = False + + self.streams = StreamContainer() + for i in range(self.ptr.nb_streams): + stream = self.ptr.streams[i] + codec = lib.avcodec_find_decoder(stream.codecpar.codec_id) + if codec: + codec_context = lib.avcodec_alloc_context3(codec) + err_check( + lib.avcodec_parameters_to_context(codec_context, stream.codecpar) + ) + codec_context.pkt_timebase = stream.time_base + py_codec_context = wrap_codec_context( + codec_context, codec, self.hwaccel + ) + if py_codec_context.is_hwaccel: + at_least_one_accelerated_context = True + else: + # no decoder is available + py_codec_context = None + self.streams.add_stream(wrap_stream(self, stream, py_codec_context)) + + if ( + self.hwaccel + and not self.hwaccel.allow_software_fallback + and not at_least_one_accelerated_context + ): + raise RuntimeError( + "Hardware accelerated decode requested but no stream is compatible" + ) + + self._metadata = avdict_to_dict( + self.ptr.metadata, self.metadata_encoding, self.metadata_errors + ) + + def __dealloc__(self): + close_input(self) + + @property + def start_time(self): + self._assert_open() + if self.ptr.start_time != lib.AV_NOPTS_VALUE: + return self.ptr.start_time + + @property + def duration(self): + self._assert_open() + if self.ptr.duration != lib.AV_NOPTS_VALUE: + return self.ptr.duration + + @property + def bit_rate(self): + self._assert_open() + return self.ptr.bit_rate + + @property + def size(self): + self._assert_open() + return lib.avio_size(self.ptr.pb) + + def close(self): + close_input(self) + + def demux(self, *args, **kwargs): + """demux(streams=None, video=None, audio=None, subtitles=None, data=None) + + Yields a series of :class:`.Packet` from the given set of :class:`.Stream`:: + + for packet in container.demux(): + # Do something with `packet`, often: + for frame in packet.decode(): + # Do something with `frame`. + + .. seealso:: :meth:`.StreamContainer.get` for the interpretation of + the arguments. + + .. note:: The last packets are dummy packets that when decoded will flush the buffers. + + """ + self._assert_open() + + streams: list[Stream] = self.streams.get(*args, **kwargs) + if self.ptr.nb_streams == 0: + return + include_stream: cython.pointer[uint8_t] = cython.cast( + cython.pointer[uint8_t], + malloc(self.ptr.nb_streams * cython.sizeof(uint8_t)), + ) + if include_stream == cython.NULL: + raise MemoryError() + + i: cython.uint + packet: Packet + read_packet: cython.pointer[lib.AVPacket] + ret: cython.int + + self.set_timeout(self.read_timeout) + try: + for i in range(self.ptr.nb_streams): + include_stream[i] = 0 + for stream in streams: + i = stream.index + if i >= self.ptr.nb_streams: + raise ValueError(f"stream index {i} out of range") + include_stream[i] = 1 + + # Pre-allocate a AVPacket that is reused as the read buffer. + with cython.nogil: + read_packet = lib.av_packet_alloc() + if read_packet == cython.NULL: + raise MemoryError("Could not allocate packet") + + while True: + # Reset the read buffer + with cython.nogil: + lib.av_packet_unref(read_packet) + try: + self.start_timeout() + with cython.nogil: + ret = lib.av_read_frame(self.ptr, read_packet) + self.err_check(ret) + except EOFError: + break + + if include_stream[read_packet.stream_index]: + # If AVFMTCTX_NOHEADER is set in ctx_flags, then new streams + # may also appear in av_read_frame(). + # http://ffmpeg.org/doxygen/trunk/structAVFormatContext.html + # TODO: find better way to handle this + if read_packet.stream_index < len(self.streams): + # Move the encoded data out of the read buffer into a + # fresh Packet for the caller. + packet = Packet() + with cython.nogil: + lib.av_packet_move_ref(packet.ptr, read_packet) + packet._stream = self.streams[packet.ptr.stream_index] + # Keep track of this so that remuxing is easier. + packet.ptr.time_base = packet._stream.ptr.time_base + yield packet + + # Flush! + for i in range(self.ptr.nb_streams): + if include_stream[i]: + packet = Packet() + packet._stream = self.streams[i] + packet.ptr.time_base = packet._stream.ptr.time_base + yield packet + + finally: + self.set_timeout(None) + free(include_stream) + if read_packet != cython.NULL: + lib.av_packet_free(cython.address(read_packet)) + + def decode(self, *args, **kwargs): + """decode(streams=None, video=None, audio=None, subtitles=None, data=None) + + Yields a series of :class:`.Frame` from the given set of streams:: + + for frame in container.decode(): + # Do something with `frame`. + + .. seealso:: :meth:`.StreamContainer.get` for the interpretation of + the arguments. + + """ + self._assert_open() + for packet in self.demux(*args, **kwargs): + yield from packet.decode() + + def seek( + self, + offset, + *, + backward: cython.bint = True, + any_frame: cython.bint = False, + stream: Stream | None = None, + unsupported_frame_offset: cython.bint = False, + unsupported_byte_offset: cython.bint = False, + ): + """seek(offset, *, backward=True, any_frame=False, stream=None) + + Seek to a (key)frame nearest to the given timestamp. + + :param int offset: Time to seek to, expressed in``stream.time_base`` if ``stream`` + is given, otherwise in :data:`av.time_base`. + :param bool backward: If there is not a (key)frame at the given offset, + look backwards for it. + :param bool any_frame: Seek to any frame, not just a keyframe. + :param Stream stream: The stream who's ``time_base`` the ``offset`` is in. + + :param bool unsupported_frame_offset: ``offset`` is a frame + index instead of a time; not supported by any known format. + :param bool unsupported_byte_offset: ``offset`` is a byte + location in the file; not supported by any known format. + + After seeking, packets that you demux should correspond (roughly) to + the position you requested. + + In most cases, the defaults of ``backwards = True`` and ``any_frame = False`` + are the best course of action, followed by you demuxing/decoding to + the position that you want. This is because to properly decode video frames + you need to start from the previous keyframe. + + .. seealso:: :ffmpeg:`avformat_seek_file` for discussion of the flags. + + """ + self._assert_open() + + if not isinstance(offset, int): + raise TypeError("Container.seek only accepts integer offset.", type(offset)) + + c_offset: int64_t = offset + flags: cython.int = 0 + ret: cython.int + + if backward: + flags |= lib.AVSEEK_FLAG_BACKWARD + if any_frame: + flags |= lib.AVSEEK_FLAG_ANY + + # If someone really wants (and to experiment), expose these. + if unsupported_frame_offset: + flags |= lib.AVSEEK_FLAG_FRAME + if unsupported_byte_offset: + flags |= lib.AVSEEK_FLAG_BYTE + + stream_index: cython.int = stream.index if stream else -1 + with cython.nogil: + ret = lib.av_seek_frame(self.ptr, stream_index, c_offset, flags) + err_check(ret) + + self.flush_buffers() + + @cython.cfunc + def flush_buffers(self): + self._assert_open() + + stream: Stream + codec_context: CodecContext + + for stream in self.streams: + codec_context = stream.codec_context + if codec_context: + codec_context.flush_buffers() diff --git a/av/container/input.pyi b/av/container/input.pyi new file mode 100644 index 000000000..88dd07d69 --- /dev/null +++ b/av/container/input.pyi @@ -0,0 +1,89 @@ +from collections.abc import Iterator +from typing import Any, overload + +from av.audio.frame import AudioFrame +from av.audio.stream import AudioStream +from av.packet import Packet +from av.stream import AttachmentStream, DataStream, Stream +from av.subtitles.stream import SubtitleStream +from av.subtitles.subtitle import SubtitleSet +from av.video.frame import VideoFrame +from av.video.stream import VideoStream + +from .core import Container + +class InputContainer(Container): + start_time: int + duration: int | None + bit_rate: int + size: int + + @overload + def demux(self, video_stream: VideoStream) -> Iterator[Packet[VideoStream]]: ... + @overload + def demux( + self, video_streams: tuple[VideoStream, ...] + ) -> Iterator[Packet[VideoStream]]: ... + @overload + def demux(self, *, video: Any) -> Iterator[Packet[VideoStream]]: ... + @overload + def demux(self, audio_stream: AudioStream) -> Iterator[Packet[AudioStream]]: ... + @overload + def demux( + self, audio_streams: tuple[AudioStream, ...] + ) -> Iterator[Packet[AudioStream]]: ... + @overload + def demux(self, *, audio: Any) -> Iterator[Packet[AudioStream]]: ... + @overload + def demux( + self, subtitle_stream: SubtitleStream + ) -> Iterator[Packet[SubtitleStream]]: ... + @overload + def demux( + self, subtitle_streams: tuple[SubtitleStream, ...] + ) -> Iterator[Packet[SubtitleStream]]: ... + @overload + def demux(self, data_stream: DataStream) -> Iterator[Packet[DataStream]]: ... + @overload + def demux( + self, data_streams: tuple[DataStream, ...] + ) -> Iterator[Packet[DataStream]]: ... + @overload + def demux(self, *, data: Any) -> Iterator[Packet[DataStream]]: ... + @overload + def demux( + self, attachment_stream: AttachmentStream + ) -> Iterator[Packet[AttachmentStream]]: ... + @overload + def demux( + self, attachment_streams: tuple[AttachmentStream, ...] + ) -> Iterator[Packet[AttachmentStream]]: ... + @overload + def demux(self, *args: Any, **kwargs: Any) -> Iterator[Packet[Stream]]: ... + @overload + def decode(self, video: int) -> Iterator[VideoFrame]: ... + @overload + def decode(self, audio: int) -> Iterator[AudioFrame]: ... + @overload + def decode(self, subtitles: int) -> Iterator[SubtitleSet]: ... + @overload + def decode(self, *args: VideoStream) -> Iterator[VideoFrame]: ... + @overload + def decode(self, *args: AudioStream) -> Iterator[AudioFrame]: ... + @overload + def decode(self, *args: SubtitleStream) -> Iterator[SubtitleSet]: ... + @overload + def decode( + self, *args: Any, **kwargs: Any + ) -> Iterator[VideoFrame | AudioFrame | SubtitleSet]: ... + def seek( + self, + offset: int, + *, + backward: bool = True, + any_frame: bool = False, + stream: Stream | VideoStream | AudioStream | None = None, + unsupported_frame_offset: bool = False, + unsupported_byte_offset: bool = False, + ) -> None: ... + def flush_buffers(self) -> None: ... diff --git a/av/container/input.pyx b/av/container/input.pyx deleted file mode 100644 index 64612d84e..000000000 --- a/av/container/input.pyx +++ /dev/null @@ -1,260 +0,0 @@ -from libc.stdint cimport int64_t -from libc.stdlib cimport free, malloc - -from av.container.streams cimport StreamContainer -from av.dictionary cimport _Dictionary -from av.error cimport err_check -from av.packet cimport Packet -from av.stream cimport Stream, wrap_stream -from av.utils cimport avdict_to_dict - -from av.dictionary import Dictionary - - -cdef close_input(InputContainer self): - if self.input_was_opened: - with nogil: - lib.avformat_close_input(&self.ptr) - self.input_was_opened = False - - -cdef class InputContainer(Container): - - def __cinit__(self, *args, **kwargs): - - cdef unsigned int i - - # If we have either the global `options`, or a `stream_options`, prepare - # a mashup of those options for each stream. - cdef lib.AVDictionary **c_options = NULL - cdef _Dictionary base_dict, stream_dict - if self.options or self.stream_options: - base_dict = Dictionary(self.options) - c_options = malloc(self.ptr.nb_streams * sizeof(void*)) - for i in range(self.ptr.nb_streams): - c_options[i] = NULL - if i < len(self.stream_options) and self.stream_options: - stream_dict = base_dict.copy() - stream_dict.update(self.stream_options[i]) - lib.av_dict_copy(&c_options[i], stream_dict.ptr, 0) - else: - lib.av_dict_copy(&c_options[i], base_dict.ptr, 0) - - self.set_timeout(self.open_timeout) - self.start_timeout() - with nogil: - # This peeks are the first few frames to: - # - set stream.disposition from codec.audio_service_type (not exposed); - # - set stream.codec.bits_per_coded_sample; - # - set stream.duration; - # - set stream.start_time; - # - set stream.r_frame_rate to average value; - # - open and closes codecs with the options provided. - ret = lib.avformat_find_stream_info( - self.ptr, - c_options - ) - self.set_timeout(None) - self.err_check(ret) - - # Cleanup all of our options. - if c_options: - for i in range(self.ptr.nb_streams): - lib.av_dict_free(&c_options[i]) - free(c_options) - - self.streams = StreamContainer() - for i in range(self.ptr.nb_streams): - self.streams.add_stream(wrap_stream(self, self.ptr.streams[i])) - - self.metadata = avdict_to_dict(self.ptr.metadata, self.metadata_encoding, self.metadata_errors) - - def __dealloc__(self): - close_input(self) - - property start_time: - def __get__(self): return self.ptr.start_time - - property duration: - def __get__(self): return self.ptr.duration - - property bit_rate: - def __get__(self): return self.ptr.bit_rate - - property size: - def __get__(self): return lib.avio_size(self.ptr.pb) - - def close(self): - close_input(self) - - def demux(self, *args, **kwargs): - """demux(streams=None, video=None, audio=None, subtitles=None, data=None) - - Yields a series of :class:`.Packet` from the given set of :class:`.Stream`:: - - for packet in container.demux(): - # Do something with `packet`, often: - for frame in packet.decode(): - # Do something with `frame`. - - .. seealso:: :meth:`.StreamContainer.get` for the interpretation of - the arguments. - - .. note:: The last packets are dummy packets that when decoded will flush the buffers. - - """ - - # For whatever reason, Cython does not like us directly passing kwargs - # from one method to another. Without kwargs, it ends up passing a - # NULL reference, which segfaults. So we force it to do something with it. - # This is likely a bug in Cython; see https://github.com/cython/cython/issues/2166 - # (and others). - id(kwargs) - - streams = self.streams.get(*args, **kwargs) - - cdef bint *include_stream = malloc(self.ptr.nb_streams * sizeof(bint)) - if include_stream == NULL: - raise MemoryError() - - cdef unsigned int i - cdef Packet packet - cdef int ret - - self.set_timeout(self.read_timeout) - try: - - for i in range(self.ptr.nb_streams): - include_stream[i] = False - for stream in streams: - i = stream.index - if i >= self.ptr.nb_streams: - raise ValueError('stream index %d out of range' % i) - include_stream[i] = True - - while True: - - packet = Packet() - try: - self.start_timeout() - with nogil: - ret = lib.av_read_frame(self.ptr, &packet.struct) - self.err_check(ret) - except EOFError: - break - - if include_stream[packet.struct.stream_index]: - # If AVFMTCTX_NOHEADER is set in ctx_flags, then new streams - # may also appear in av_read_frame(). - # http://ffmpeg.org/doxygen/trunk/structAVFormatContext.html - # TODO: find better way to handle this - if packet.struct.stream_index < len(self.streams): - packet._stream = self.streams[packet.struct.stream_index] - # Keep track of this so that remuxing is easier. - packet._time_base = packet._stream._stream.time_base - yield packet - - # Flush! - for i in range(self.ptr.nb_streams): - if include_stream[i]: - packet = Packet() - packet._stream = self.streams[i] - packet._time_base = packet._stream._stream.time_base - yield packet - - finally: - self.set_timeout(None) - free(include_stream) - - def decode(self, *args, **kwargs): - """decode(streams=None, video=None, audio=None, subtitles=None, data=None) - - Yields a series of :class:`.Frame` from the given set of streams:: - - for frame in container.decode(): - # Do something with `frame`. - - .. seealso:: :meth:`.StreamContainer.get` for the interpretation of - the arguments. - - """ - id(kwargs) # Avoid Cython bug; see demux(). - for packet in self.demux(*args, **kwargs): - for frame in packet.decode(): - yield frame - - def seek(self, offset, *, str whence='time', bint backward=True, - bint any_frame=False, Stream stream=None, - bint unsupported_frame_offset=False, - bint unsupported_byte_offset=False): - """seek(offset, *, backward=True, any_frame=False, stream=None) - - Seek to a (key)frame nearsest to the given timestamp. - - :param int offset: Time to seek to, expressed in``stream.time_base`` if ``stream`` - is given, otherwise in :data:`av.time_base`. - :param bool backward: If there is not a (key)frame at the given offset, - look backwards for it. - :param bool any_frame: Seek to any frame, not just a keyframe. - :param Stream stream: The stream who's ``time_base`` the ``offset`` is in. - - :param bool unsupported_frame_offset: ``offset`` is a frame - index instead of a time; not supported by any known format. - :param bool unsupported_byte_offset: ``offset`` is a byte - location in the file; not supported by any known format. - - After seeking, packets that you demux should correspond (roughly) to - the position you requested. - - In most cases, the defaults of ``backwards = True`` and ``any_frame = False`` - are the best course of action, followed by you demuxing/decoding to - the position that you want. This is becase to properly decode video frames - you need to start from the previous keyframe. - - .. seealso:: :ffmpeg:`avformat_seek_file` for discussion of the flags. - - """ - - # We used to take floats here and assume they were in seconds. This - # was super confusing, so lets go in the complete opposite direction - # and reject non-ints. - if not isinstance(offset, (int, long)): - raise TypeError('Container.seek only accepts integer offset.', type(offset)) - cdef int64_t c_offset = offset - - cdef int flags = 0 - cdef int ret - - # We used to support whence in 'time', 'frame', and 'byte', but later - # realized that FFmpged doens't implement the frame or byte ones. - # We don't even document this anymore, but do allow 'time' to pass through. - if whence != 'time': - raise ValueError("whence != 'time' is no longer supported") - - if backward: - flags |= lib.AVSEEK_FLAG_BACKWARD - if any_frame: - flags |= lib.AVSEEK_FLAG_ANY - - # If someone really wants (and to experiment), expose these. - if unsupported_frame_offset: - flags |= lib.AVSEEK_FLAG_FRAME - if unsupported_byte_offset: - flags |= lib.AVSEEK_FLAG_BYTE - - cdef int stream_index = stream.index if stream else -1 - with nogil: - ret = lib.av_seek_frame(self.ptr, stream_index, c_offset, flags) - err_check(ret) - - self.flush_buffers() - - cdef flush_buffers(self): - cdef unsigned int i - cdef lib.AVStream *stream - - with nogil: - for i in range(self.ptr.nb_streams): - stream = self.ptr.streams[i] - if stream.codec and stream.codec.codec and stream.codec.codec_id != lib.AV_CODEC_ID_NONE: - lib.avcodec_flush_buffers(stream.codec) diff --git a/av/container/output.pxd b/av/container/output.pxd index f65dba657..2ef93206f 100644 --- a/av/container/output.pxd +++ b/av/container/output.pxd @@ -1,12 +1,15 @@ cimport libav as lib from av.container.core cimport Container +from av.packet cimport Packet from av.stream cimport Stream cdef class OutputContainer(Container): - - cdef bint _started - cdef bint _done - + cdef lib.AVPacket *packet_ptr + cdef dict _extradata_bsfs + cdef list _buffered_packets + cdef _mux_one(self, Packet packet) + cdef _buffer_for_extradata(self, Packet packet) + cdef _try_extract_extradata(self, Packet packet) cpdef start_encoding(self) diff --git a/av/container/output.py b/av/container/output.py new file mode 100644 index 000000000..a33dadd61 --- /dev/null +++ b/av/container/output.py @@ -0,0 +1,718 @@ +import os + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.bitstream import BitStreamFilterContext +from cython.cimports.av.codec.codec import Codec +from cython.cimports.av.codec.context import CodecContext, wrap_codec_context +from cython.cimports.av.codec.hwaccel import HWAccel +from cython.cimports.av.container.streams import StreamContainer +from cython.cimports.av.dictionary import Dictionary +from cython.cimports.av.error import err_check +from cython.cimports.av.packet import Packet +from cython.cimports.av.stream import Stream, wrap_stream +from cython.cimports.av.utils import dict_to_avdict, to_avrational +from cython.cimports.libc.stdint import uint8_t +from cython.cimports.libc.string import memcpy, memset + + +@cython.cfunc +def _set_codecpar_extradata( + stream: cython.pointer[lib.AVStream], + data: cython.pointer[uint8_t], + size: cython.int, +): + buf: cython.p_uchar = cython.cast( + cython.p_uchar, lib.av_malloc(size + lib.AV_INPUT_BUFFER_PADDING_SIZE) + ) + if buf == cython.NULL: + raise MemoryError("Could not allocate extradata") + + memcpy(buf, data, size) + memset(buf + size, 0, lib.AV_INPUT_BUFFER_PADDING_SIZE) + + lib.av_freep(cython.address(stream.codecpar.extradata)) + stream.codecpar.extradata = buf + stream.codecpar.extradata_size = size + + +@cython.cfunc +def close_output(self: OutputContainer): + if self.packet_ptr != cython.NULL and self._buffered_packets: + buffered: list = self._buffered_packets + self._buffered_packets = [] + packet: Packet + for packet in buffered: + self._mux_one(packet) + + self.streams = StreamContainer() + if self._myflag & 12 == 4: # enum.started and not enum.done + # If the underlying Python IO file was already closed (e.g. during GC + # finalization where cycle ordering is undefined), skip the trailer. + if self.file is not None and getattr(self.file.file, "closed", False): + self._myflag |= 8 # enum.done = True + return + # We must only ever call av_write_trailer *once*, otherwise we get a + # segmentation fault. Therefore no matter whether it succeeds or not + # we must absolutely set enum.done. + try: + self.err_check(lib.av_write_trailer(self.ptr)) + finally: + if self.file is None and not (self.ptr.oformat.flags & lib.AVFMT_NOFILE): + lib.avio_closep(cython.address(self.ptr.pb)) + self._myflag |= 8 # enum.done = True + + +@cython.final +@cython.cclass +class OutputContainer(Container): + def __cinit__(self, *args, **kwargs): + self.streams = StreamContainer() + self._extradata_bsfs = {} + self._buffered_packets = [] + with cython.nogil: + self.packet_ptr = lib.av_packet_alloc() + + def __del__(self): + close_output(self) + + def __dealloc__(self): + with cython.nogil: + lib.av_packet_free(cython.address(self.packet_ptr)) + + def add_stream( + self, + codec_name, + rate=None, + options: dict | None = None, + hwaccel: HWAccel | None = None, + **kwargs, + ): + """add_stream(codec_name, rate=None, *, hwaccel=None) + + Creates a new stream from a codec name and returns it. + Supports video, audio, and subtitle streams. + + :param codec_name: The name of a codec. + :type codec_name: str + :param dict options: Stream options. + :param HWAccel hwaccel: Optional settings for hardware-accelerated encoding. + Only applies to video streams (e.g. ``h264_vaapi``); software frames + passed to :meth:`~av.codec.context.CodecContext.encode` are uploaded to + the device automatically. + :param \\**kwargs: Set attributes for the stream. + :rtype: The new :class:`~av.stream.Stream`. + + .. warning:: + + Configure every output stream before muxing the first packet. Writing + the file header opens all stream codec contexts, after which structural + properties such as format, dimensions, layout, and rate cannot change. + + """ + + codec_obj: Codec = Codec(codec_name, "w") + codec: cython.pointer[cython.const[lib.AVCodec]] = codec_obj.ptr + + # Assert that this format supports the requested codec. + if not lib.avformat_query_codec( + self.ptr.oformat, codec.id, lib.FF_COMPLIANCE_NORMAL + ): + raise ValueError( + f"{self.format.name!r} format does not support {codec_obj.name!r} codec" + ) + + # Create new stream in the AVFormatContext, set AVCodecContext values. + stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream(self.ptr, codec) + ctx: cython.pointer[lib.AVCodecContext] = lib.avcodec_alloc_context3(codec) + + # Now lets set some more sane video defaults + if codec.type == lib.AVMEDIA_TYPE_VIDEO: + ctx.pix_fmt = lib.AV_PIX_FMT_YUV420P + ctx.width = kwargs.pop("width", 640) + ctx.height = kwargs.pop("height", 480) + ctx.bit_rate = kwargs.pop("bit_rate", 0) + ctx.bit_rate_tolerance = kwargs.pop("bit_rate_tolerance", 128000) + try: + to_avrational(kwargs.pop("time_base"), cython.address(ctx.time_base)) + except KeyError: + pass + to_avrational(rate or 24, cython.address(ctx.framerate)) + + stream.avg_frame_rate = ctx.framerate + stream.time_base = ctx.time_base + + # Some sane audio defaults + elif codec.type == lib.AVMEDIA_TYPE_AUDIO: + out: cython.pointer[cython.const[cython.void]] = cython.NULL + lib.avcodec_get_supported_config( + cython.NULL, + codec, + lib.AV_CODEC_CONFIG_SAMPLE_FORMAT, + 0, + cython.address(out), + cython.NULL, + ) + if out: + ctx.sample_fmt = cython.cast(cython.pointer[lib.AVSampleFormat], out)[0] + ctx.bit_rate = kwargs.pop("bit_rate", 0) + ctx.bit_rate_tolerance = kwargs.pop("bit_rate_tolerance", 32000) + try: + to_avrational(kwargs.pop("time_base"), cython.address(ctx.time_base)) + except KeyError: + pass + + if rate is None: + ctx.sample_rate = 48000 + elif type(rate) is int: + ctx.sample_rate = rate + else: + raise TypeError("audio stream `rate` must be: int | None") + stream.time_base = ctx.time_base + lib.av_channel_layout_default(cython.address(ctx.ch_layout), 2) + + # Some formats want stream headers to be separate + if self.ptr.oformat.flags & lib.AVFMT_GLOBALHEADER: + ctx.flags |= lib.AV_CODEC_FLAG_GLOBAL_HEADER + + # Initialise stream codec parameters to populate the codec type. + # + # Subsequent changes to the codec context will be applied just before + # encoding starts in `start_encoding()`. + err_check(lib.avcodec_parameters_from_context(stream.codecpar, ctx)) + + # Construct the user-land stream + py_codec_context: CodecContext = wrap_codec_context(ctx, codec, hwaccel) + py_stream: Stream = wrap_stream(self, stream, py_codec_context) + self.streams.add_stream(py_stream) + + if options: + py_stream.options.update(options) + + for k, v in kwargs.items(): + setattr(py_stream, k, v) + + return py_stream + + def add_mux_stream(self, codec_name: str, rate=None, **kwargs) -> Stream: + """add_mux_stream(codec_name, rate=None) + + Creates a new stream for muxing pre-encoded data without creating a + :class:`.CodecContext`. Use this when you want to mux packets that were + already encoded externally and no encoding/decoding is needed. + + :param codec_name: The name of a codec. + :type codec_name: str + :param \\**kwargs: Set attributes for the stream (e.g. ``width``, ``height``, + ``time_base``). + :rtype: The new :class:`~av.stream.Stream`. + + """ + # Find the codec to get its id and type (try encoder first, then decoder). + codec_name_bytes: bytes = codec_name.encode() + codec: cython.pointer[cython.const[lib.AVCodec]] = ( + lib.avcodec_find_encoder_by_name(codec_name_bytes) + ) + codec_descriptor: cython.pointer[cython.const[lib.AVCodecDescriptor]] = ( + cython.NULL + ) + if codec == cython.NULL: + codec = lib.avcodec_find_decoder_by_name(codec_name_bytes) + if codec == cython.NULL: + codec_descriptor = lib.avcodec_descriptor_get_by_name(codec_name_bytes) + if codec_descriptor == cython.NULL: + raise ValueError(f"Unknown codec: {codec_name!r}") + + codec_id: lib.AVCodecID + codec_type: lib.AVMediaType + if codec != cython.NULL: + codec_id = codec.id + codec_type = codec.type + else: + codec_id = codec_descriptor.id + codec_type = codec_descriptor.type + + # Assert that this format supports the requested codec. + if not lib.avformat_query_codec( + self.ptr.oformat, codec_id, lib.FF_COMPLIANCE_NORMAL + ): + raise ValueError( + f"{self.format.name!r} format does not support {codec_name!r} codec" + ) + + # Create stream with no codec context. + stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream( + self.ptr, cython.NULL + ) + if stream == cython.NULL: + raise MemoryError("Could not allocate stream") + + stream.codecpar.codec_id = codec_id + stream.codecpar.codec_type = codec_type + + if codec_type == lib.AVMEDIA_TYPE_VIDEO: + stream.codecpar.width = kwargs.pop("width", 0) + stream.codecpar.height = kwargs.pop("height", 0) + if rate is not None: + to_avrational(rate, cython.address(stream.avg_frame_rate)) + elif codec_type == lib.AVMEDIA_TYPE_AUDIO: + if rate is not None: + if type(rate) is int: + stream.codecpar.sample_rate = rate + else: + raise TypeError("audio stream `rate` must be: int | None") + + # Construct the user-land stream (no codec context). + py_stream: Stream = wrap_stream(self, stream, None) + self.streams.add_stream(py_stream) + + for k, v in kwargs.items(): + setattr(py_stream, k, v) + + return py_stream + + def add_stream_from_template( + self, template: Stream, opaque: bool | None = None, **kwargs + ): + """ + Creates a new stream from a template. Supports video, audio, subtitle, data and attachment streams. + + :param template: Copy codec from another :class:`~av.stream.Stream` instance. + :param opaque: If True, copy opaque data from the template's codec context. + :param \\**kwargs: Set attributes for the stream. + :rtype: The new :class:`~av.stream.Stream`. + """ + if opaque is None: + opaque = template.type != "video" + + if template.codec_context is None: + return self._add_stream_without_codec_from_template(template, **kwargs) + + codec_obj: Codec + if opaque: # Copy ctx from template. + codec_obj = template.codec_context.codec + else: # Construct new codec object. + codec_obj = Codec(template.codec_context.codec.name, "w") + + codec: cython.pointer[cython.const[lib.AVCodec]] = codec_obj.ptr + + # Assert that this format supports the requested codec. + if not lib.avformat_query_codec( + self.ptr.oformat, codec.id, lib.FF_COMPLIANCE_NORMAL + ): + raise ValueError( + f"{self.format.name!r} format does not support {codec_obj.name!r} codec" + ) + + # Create new stream in the AVFormatContext, set AVCodecContext values. + stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream(self.ptr, codec) + ctx: cython.pointer[lib.AVCodecContext] = lib.avcodec_alloc_context3(codec) + + err_check(lib.avcodec_parameters_to_context(ctx, template.ptr.codecpar)) + # Reset the codec tag assuming we are remuxing. + ctx.codec_tag = 0 + + # Copy the template's stream time_base + stream.time_base = template.ptr.time_base + ctx.time_base = template.ptr.time_base + + # Some formats want stream headers to be separate + if self.ptr.oformat.flags & lib.AVFMT_GLOBALHEADER: + ctx.flags |= lib.AV_CODEC_FLAG_GLOBAL_HEADER + + # Copy flags If we're creating a new codec object. This fixes some muxing issues. + # Overwriting `ctx.flags |= lib.AV_CODEC_FLAG_GLOBAL_HEADER` is intentional. + if not opaque: + ctx.flags = template.codec_context.flags + + # Initialize stream codec parameters to populate the codec type. Subsequent changes to + # the codec context will be applied just before encoding starts in `start_encoding()`. + err_check(lib.avcodec_parameters_from_context(stream.codecpar, ctx)) + + # Construct the user-land stream + py_codec_context: CodecContext = wrap_codec_context(ctx, codec, None) + py_codec_context._ctxflags |= 1 # _template_initialized = True + py_stream: Stream = wrap_stream(self, stream, py_codec_context) + self.streams.add_stream(py_stream) + + for k, v in kwargs.items(): + setattr(py_stream, k, v) + + return py_stream + + def _add_stream_without_codec_from_template( + self, template: Stream, **kwargs + ) -> Stream: + codec_type: cython.int = template.ptr.codecpar.codec_type + if codec_type not in {lib.AVMEDIA_TYPE_ATTACHMENT, lib.AVMEDIA_TYPE_DATA}: + raise ValueError( + f"template stream of type {template.type} has no codec context" + ) + + stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream( + self.ptr, cython.NULL + ) + if stream == cython.NULL: + raise MemoryError("Could not allocate stream") + + err_check(lib.avcodec_parameters_copy(stream.codecpar, template.ptr.codecpar)) + + # Mirror basic properties that are not derived from a codec context. + stream.time_base = template.ptr.time_base + stream.start_time = template.ptr.start_time + stream.duration = template.ptr.duration + stream.disposition = template.ptr.disposition + + py_stream: Stream = wrap_stream(self, stream, None) + self.streams.add_stream(py_stream) + + py_stream.metadata = dict(template.metadata) + + for k, v in kwargs.items(): + setattr(py_stream, k, v) + + return py_stream + + def add_attachment(self, name: str, mimetype: str, data: bytes): + """ + Create an attachment stream and embed its payload into the container header. + + - Only supported by formats that support attachments (e.g. Matroska). + - No per-packet muxing is required; attachments are written at header time. + """ + # Create stream with no codec (attachments are codec-less). + stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream( + self.ptr, cython.NULL + ) + if stream == cython.NULL: + raise MemoryError("Could not allocate stream") + + stream.codecpar.codec_type = lib.AVMEDIA_TYPE_ATTACHMENT + stream.codecpar.codec_id = lib.AV_CODEC_ID_NONE + + # Allocate and copy payload into codecpar.extradata. + payload_size: cython.size_t = len(data) + if payload_size: + buf = cython.cast(cython.p_uchar, lib.av_malloc(payload_size + 1)) + if buf == cython.NULL: + raise MemoryError("Could not allocate attachment data") + # Copy bytes. + for i in range(payload_size): + buf[i] = data[i] + buf[payload_size] = 0 + stream.codecpar.extradata = cython.cast(cython.p_uchar, buf) + stream.codecpar.extradata_size = cython.cast(cython.int, payload_size) + + # Wrap as user-land stream. + meta_ptr = cython.address(stream.metadata) + err_check(lib.av_dict_set(meta_ptr, b"filename", name.encode(), 0)) + mime_bytes = mimetype.encode() + err_check(lib.av_dict_set(meta_ptr, b"mimetype", mime_bytes, 0)) + + py_stream: Stream = wrap_stream(self, stream, None) + self.streams.add_stream(py_stream) + return py_stream + + def add_data_stream(self, codec_name=None, options: dict | None = None): + """add_data_stream(codec_name=None) + + Creates a new data stream and returns it. + + :param codec_name: Optional name of the data codec (e.g. 'klv') + :type codec_name: str | None + :param dict options: Stream options. + :rtype: The new :class:`~av.data.stream.DataStream`. + """ + codec: cython.pointer[cython.const[lib.AVCodec]] = cython.NULL + codec_descriptor: cython.pointer[cython.const[lib.AVCodecDescriptor]] = ( + cython.NULL + ) + + if codec_name is not None: + codec_name_bytes: bytes = codec_name.encode() + codec = lib.avcodec_find_encoder_by_name(codec_name_bytes) + if codec == cython.NULL: + codec = lib.avcodec_find_decoder_by_name(codec_name_bytes) + if codec == cython.NULL: + codec_descriptor = lib.avcodec_descriptor_get_by_name(codec_name_bytes) + if codec_descriptor == cython.NULL: + raise ValueError(f"Unknown data codec: {codec_name}") + + # Verify format supports this codec + codec_id = codec.id if codec != cython.NULL else codec_descriptor.id + if not lib.avformat_query_codec( + self.ptr.oformat, codec_id, lib.FF_COMPLIANCE_NORMAL + ): + raise ValueError( + f"{self.format.name!r} format does not support {codec_name!r} codec" + ) + + # Create new stream in the AVFormatContext + stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream(self.ptr, codec) + if stream == cython.NULL: + raise MemoryError("Could not allocate stream") + + # Set up codec context and parameters + ctx: cython.pointer[lib.AVCodecContext] = cython.NULL + if codec != cython.NULL: + ctx = lib.avcodec_alloc_context3(codec) + if ctx == cython.NULL: + raise MemoryError("Could not allocate codec context") + + # Some formats want stream headers to be separate + if self.ptr.oformat.flags & lib.AVFMT_GLOBALHEADER: + ctx.flags |= lib.AV_CODEC_FLAG_GLOBAL_HEADER + + # Initialize stream codec parameters + err_check(lib.avcodec_parameters_from_context(stream.codecpar, ctx)) + else: + # No codec available - set basic parameters for data stream + stream.codecpar.codec_type = lib.AVMEDIA_TYPE_DATA + if codec_descriptor != cython.NULL: + stream.codecpar.codec_id = codec_descriptor.id + + # Construct the user-land stream + py_codec_context: CodecContext | None = None + if ctx != cython.NULL: + py_codec_context = wrap_codec_context(ctx, codec, None) + + py_stream: Stream = wrap_stream(self, stream, py_codec_context) + self.streams.add_stream(py_stream) + + if options: + py_stream.options.update(options) + + return py_stream + + @cython.ccall + def start_encoding(self): + """Write the file header! Called automatically.""" + if self._myflag & 4: # started + return + + # TODO: This does NOT handle options coming from 3 sources. + # This is only a rough approximation of what would be cool to do. + used_options: set = set() + stream: Stream + + # Finalize and open all streams. + for stream in self.streams: + ctx = stream.codec_context + if ctx is not None and not ctx.is_open: + for k, v in self.options.items(): + ctx.options.setdefault(k, v) + + if not (ctx._ctxflags & 1): # template_initialized + ctx.open() + + # Track option consumption. + for k in self.options: + if k not in ctx.options: + used_options.add(k) + + stream._finalize_for_output() + + # Open the output file, if needed. + name_obj: bytes = os.fsencode(self.name if self.file is None else "") + name: cython.p_char = name_obj + if self.ptr.pb == cython.NULL and not self.ptr.oformat.flags & lib.AVFMT_NOFILE: + err_check( + lib.avio_open(cython.address(self.ptr.pb), name, lib.AVIO_FLAG_WRITE) + ) + + # Copy the metadata dict. + dict_to_avdict( + cython.address(self.ptr.metadata), + self.metadata, + encoding=self.metadata_encoding, + errors=self.metadata_errors, + ) + + all_options: Dictionary = Dictionary(self.options, self.container_options) + options: Dictionary = all_options.copy() + self.err_check(lib.avformat_write_header(self.ptr, cython.address(options.ptr))) + + # Track option usage... + for k in all_options: + if k not in options: + used_options.add(k) + + # ... and warn if any weren't used. + unused_options = { + k: v for k, v in self.options.items() if k not in used_options + } + if unused_options: + import logging + + log = logging.getLogger(__name__) + log.warning(f"Some options were not used: {unused_options}") + + self._myflag |= 4 + + @property + def supported_codecs(self): + """ + Returns a set of all codecs this format supports. + """ + result: set = set() + codec: cython.pointer[cython.const[lib.AVCodec]] = cython.NULL + opaque: cython.p_void = cython.NULL + + while True: + codec = lib.av_codec_iterate(cython.address(opaque)) + if codec == cython.NULL: + break + + if ( + lib.avformat_query_codec( + self.ptr.oformat, codec.id, lib.FF_COMPLIANCE_NORMAL + ) + == 1 + ): + result.add(codec.name) + + return result + + @property + def default_video_codec(self): + """ + Returns the default video codec this container recommends. + """ + return lib.avcodec_get_name(self.format.optr.video_codec) + + @property + def default_audio_codec(self): + """ + Returns the default audio codec this container recommends. + """ + return lib.avcodec_get_name(self.format.optr.audio_codec) + + @property + def default_subtitle_codec(self): + """ + Returns the default subtitle codec this container recommends. + """ + return lib.avcodec_get_name(self.format.optr.subtitle_codec) + + def close(self): + close_output(self) + + def mux(self, packets): + # We accept either a Packet, or a sequence of packets. This should smooth out + # the transition to the new encode API which returns a sequence of packets. + if isinstance(packets, Packet): + self.mux_one(packets) + else: + for packet in packets: + self.mux_one(packet) + + def mux_one(self, packet: Packet): + if not (self._myflag & 4) and self._buffer_for_extradata(packet): + return + + self._mux_one(packet) + + @cython.cfunc + def _mux_one(self, packet: Packet): + self.start_encoding() + + # Assert the packet is in stream time. + if ( + packet.ptr.stream_index < 0 + or cython.cast(cython.uint, packet.ptr.stream_index) >= self.ptr.nb_streams + ): + raise ValueError("Bad Packet stream_index.") + + stream: cython.pointer[lib.AVStream] = self.ptr.streams[packet.ptr.stream_index] + packet._rebase_time(stream.time_base) + + # Make another reference to the packet, as `av_interleaved_write_frame()` + # takes ownership of the reference. + self.err_check(lib.av_packet_ref(self.packet_ptr, packet.ptr)) + + with cython.nogil: + ret: cython.int = lib.av_interleaved_write_frame(self.ptr, self.packet_ptr) + self.err_check(ret) + + @cython.cfunc + def _buffer_for_extradata(self, packet: Packet): + """Buffer ``packet`` until extradata is known for all mux streams that + need it. Returns True if the packet was buffered (caller should stop).""" + if not (self._myflag & 16): # extradata_planned + self._myflag |= 16 + if self.ptr.oformat.flags & lib.AVFMT_GLOBALHEADER: + stream: Stream + for stream in self.streams: + if ( + stream.codec_context is not None + or stream.ptr.codecpar.extradata != cython.NULL + ): + continue + try: + bsf = BitStreamFilterContext( + "extract_extradata", in_stream=stream + ) + except Exception: + continue # Codec does not support extradata extraction. + self._extradata_bsfs[stream.ptr.index] = bsf + + if not self._extradata_bsfs: + return False # Nothing to wait for; mux normally. + + self._try_extract_extradata(packet) + self._buffered_packets.append(packet) + if self._extradata_bsfs: + return True # Still waiting on some stream's extradata. + + # All extradata is resolved: write the header and flush buffered packets. + buffered: list = self._buffered_packets + self._buffered_packets = [] + buffered_packet: Packet + for buffered_packet in buffered: + self._mux_one(buffered_packet) + return True + + @cython.cfunc + def _try_extract_extradata(self, packet: Packet): + idx: cython.int = packet.ptr.stream_index + if idx not in self._extradata_bsfs: + return + + bsf_wrapper: BitStreamFilterContext = self._extradata_bsfs[idx] + bsf: cython.pointer[lib.AVBSFContext] = bsf_wrapper.ptr + + tmp: cython.pointer[lib.AVPacket] = lib.av_packet_alloc() + if tmp == cython.NULL: + raise MemoryError("Could not allocate packet") + + size: cython.size_t = 0 + sd: cython.pointer[uint8_t] + try: + # Clone the packet so the filter does not consume the caller's data. + if lib.av_packet_ref(tmp, packet.ptr) < 0: + return + + if lib.av_bsf_send_packet(bsf, tmp) < 0: + lib.av_packet_unref(tmp) # send failed; we still own the ref + return + + # The filter rejects packets that are already length-prefixed rather + # than annex-b, returning an error here; treat that and EOF/EAGAIN + # alike as "no in-band extradata". + while lib.av_bsf_receive_packet(bsf, tmp) == 0: + sd = lib.av_packet_get_side_data( + tmp, lib.AV_PKT_DATA_NEW_EXTRADATA, cython.address(size) + ) + if sd != cython.NULL and size > 0: + _set_codecpar_extradata( + self.ptr.streams[idx], sd, cython.cast(cython.int, size) + ) + lib.av_packet_unref(tmp) + break + lib.av_packet_unref(tmp) + finally: + lib.av_packet_free(cython.address(tmp)) + # A stream's first packet is the only reliable place to find in-band + # parameter sets, so stop waiting on this stream regardless of the + # result, falling back to the muxer's default behavior. + del self._extradata_bsfs[idx] diff --git a/av/container/output.pyi b/av/container/output.pyi new file mode 100644 index 000000000..961e7ad2d --- /dev/null +++ b/av/container/output.pyi @@ -0,0 +1,79 @@ +from collections.abc import Sequence +from fractions import Fraction +from typing import TypeVar, overload + +from av.audio import _AudioCodecName +from av.audio.stream import AudioStream +from av.codec.hwaccel import HWAccel +from av.packet import Packet +from av.stream import AttachmentStream, DataStream, Stream +from av.subtitles import _SubtitleCodecName +from av.subtitles.stream import SubtitleStream +from av.video import _VideoCodecName +from av.video.stream import VideoStream + +from .core import Container + +_StreamT = TypeVar("_StreamT", bound=Stream) + +class OutputContainer(Container): + @overload + def add_stream( + self, + codec_name: _AudioCodecName, + rate: int | None = None, + options: dict[str, str] | None = None, + **kwargs, + ) -> AudioStream: ... + @overload + def add_stream( + self, + codec_name: _VideoCodecName, + rate: Fraction | int | None = None, + options: dict[str, str] | None = None, + hwaccel: HWAccel | None = None, + **kwargs, + ) -> VideoStream: ... + @overload + def add_stream( + self, + codec_name: _SubtitleCodecName, + rate: Fraction | int | None = None, + options: dict[str, str] | None = None, + **kwargs, + ) -> SubtitleStream: ... + @overload + def add_stream( + self, + codec_name: str, + rate: Fraction | int | None = None, + options: dict[str, str] | None = None, + hwaccel: HWAccel | None = None, + **kwargs, + ) -> VideoStream | AudioStream | SubtitleStream: ... + def add_mux_stream( + self, + codec_name: str, + rate: Fraction | int | None = None, + **kwargs, + ) -> Stream: ... + def add_stream_from_template( + self, template: _StreamT, opaque: bool | None = None, **kwargs + ) -> _StreamT: ... + def add_attachment( + self, name: str, mimetype: str, data: bytes + ) -> AttachmentStream: ... + def add_data_stream( + self, codec_name: str | None = None, options: dict[str, str] | None = None + ) -> DataStream: ... + def start_encoding(self) -> None: ... + def mux(self, packets: Packet | Sequence[Packet]) -> None: ... + def mux_one(self, packet: Packet) -> None: ... + @property + def default_video_codec(self) -> str: ... + @property + def default_audio_codec(self) -> str: ... + @property + def default_subtitle_codec(self) -> str: ... + @property + def supported_codecs(self) -> set[str]: ... diff --git a/av/container/output.pyx b/av/container/output.pyx deleted file mode 100644 index 9569c3e9d..000000000 --- a/av/container/output.pyx +++ /dev/null @@ -1,227 +0,0 @@ -from fractions import Fraction -import logging -import os - -from av.codec.codec cimport Codec -from av.container.streams cimport StreamContainer -from av.dictionary cimport _Dictionary -from av.error cimport err_check -from av.packet cimport Packet -from av.stream cimport Stream, wrap_stream -from av.utils cimport dict_to_avdict - -from av.dictionary import Dictionary - - -log = logging.getLogger(__name__) - - -cdef close_output(OutputContainer self): - cdef Stream stream - - if self._started and not self._done: - self.err_check(lib.av_write_trailer(self.ptr)) - - for stream in self.streams: - stream.codec_context.close() - - if self.file is None and not self.ptr.oformat.flags & lib.AVFMT_NOFILE: - lib.avio_closep(&self.ptr.pb) - - self._done = True - - -cdef class OutputContainer(Container): - - def __cinit__(self, *args, **kwargs): - self.streams = StreamContainer() - self.metadata = {} - - def __dealloc__(self): - close_output(self) - - def add_stream(self, codec_name=None, object rate=None, Stream template=None, options=None, **kwargs): - """add_stream(codec_name, rate=None) - - Create a new stream, and return it. - - :param str codec_name: The name of a codec. - :param rate: The frame rate for video, and sample rate for audio. - Examples for video include ``24``, ``23.976``, and - ``Fraction(30000,1001)``. Examples for audio include ``48000`` - and ``44100``. - :returns: The new :class:`~av.stream.Stream`. - - """ - - if (codec_name is None and template is None) or (codec_name is not None and template is not None): - raise ValueError('needs one of codec_name or template') - - cdef const lib.AVCodec *codec - cdef Codec codec_obj - - if codec_name is not None: - codec_obj = codec_name if isinstance(codec_name, Codec) else Codec(codec_name, 'w') - codec = codec_obj.ptr - - else: - if not template._codec: - raise ValueError("template has no codec") - if not template._codec_context: - raise ValueError("template has no codec context") - codec = template._codec - - # Assert that this format supports the requested codec. - if not lib.avformat_query_codec( - self.ptr.oformat, - codec.id, - lib.FF_COMPLIANCE_NORMAL, - ): - raise ValueError("%r format does not support %r codec" % (self.format.name, codec_name)) - - # Create new stream in the AVFormatContext, set AVCodecContext values. - # As of last check, avformat_new_stream only calls avcodec_alloc_context3 to create - # the context, but doesn't modify it in any other way. Ergo, we can allow CodecContext - # to finish initializing it. - lib.avformat_new_stream(self.ptr, codec) - cdef lib.AVStream *stream = self.ptr.streams[self.ptr.nb_streams - 1] - cdef lib.AVCodecContext *codec_context = stream.codec # For readability. - - # Copy from the template. - if template is not None: - lib.avcodec_copy_context(codec_context, template._codec_context) - # Reset the codec tag assuming we are remuxing. - codec_context.codec_tag = 0 - - # Now lets set some more sane video defaults - elif codec.type == lib.AVMEDIA_TYPE_VIDEO: - codec_context.pix_fmt = lib.AV_PIX_FMT_YUV420P - codec_context.width = 640 - codec_context.height = 480 - codec_context.bit_rate = 1024000 - codec_context.bit_rate_tolerance = 128000 - codec_context.ticks_per_frame = 1 - - rate = Fraction(rate or 24) - - codec_context.framerate.num = rate.numerator - codec_context.framerate.den = rate.denominator - - stream.time_base = codec_context.time_base - - # Some sane audio defaults - elif codec.type == lib.AVMEDIA_TYPE_AUDIO: - codec_context.sample_fmt = codec.sample_fmts[0] - codec_context.bit_rate = 128000 - codec_context.bit_rate_tolerance = 32000 - codec_context.sample_rate = rate or 48000 - codec_context.channels = 2 - codec_context.channel_layout = lib.AV_CH_LAYOUT_STEREO - - # Some formats want stream headers to be separate - if self.ptr.oformat.flags & lib.AVFMT_GLOBALHEADER: - codec_context.flags |= lib.AV_CODEC_FLAG_GLOBAL_HEADER - - # Construct the user-land stream - cdef Stream py_stream = wrap_stream(self, stream) - self.streams.add_stream(py_stream) - - if options: - py_stream.options.update(options) - - for k, v in kwargs.items(): - setattr(py_stream, k, v) - - return py_stream - - cpdef start_encoding(self): - """Write the file header! Called automatically.""" - - if self._started: - return - - # TODO: This does NOT handle options coming from 3 sources. - # This is only a rough approximation of what would be cool to do. - used_options = set() - - # Finalize and open all streams. - cdef Stream stream - for stream in self.streams: - - ctx = stream.codec_context - if not ctx.is_open: - - for k, v in self.options.items(): - ctx.options.setdefault(k, v) - ctx.open() - - # Track option consumption. - for k in self.options: - if k not in ctx.options: - used_options.add(k) - - stream._finalize_for_output() - - # Open the output file, if needed. - cdef bytes name_obj = os.fsencode(self.name if self.file is None else "") - cdef char *name = name_obj - if self.ptr.pb == NULL and not self.ptr.oformat.flags & lib.AVFMT_NOFILE: - err_check(lib.avio_open(&self.ptr.pb, name, lib.AVIO_FLAG_WRITE)) - - # Copy the metadata dict. - dict_to_avdict( - &self.ptr.metadata, self.metadata, - encoding=self.metadata_encoding, - errors=self.metadata_errors - ) - - cdef _Dictionary all_options = Dictionary(self.options, self.container_options) - cdef _Dictionary options = all_options.copy() - self.err_check(lib.avformat_write_header( - self.ptr, - &options.ptr - )) - - # Track option usage... - for k in all_options: - if k not in options: - used_options.add(k) - # ... and warn if any weren't used. - unused_options = {k: v for k, v in self.options.items() if k not in used_options} - if unused_options: - log.warning('Some options were not used: %s' % unused_options) - - self._started = True - - def close(self): - close_output(self) - - def mux(self, packets): - # We accept either a Packet, or a sequence of packets. This should - # smooth out the transition to the new encode API which returns a - # sequence of packets. - if isinstance(packets, Packet): - self.mux_one(packets) - else: - for packet in packets: - self.mux_one(packet) - - def mux_one(self, Packet packet not None): - self.start_encoding() - - # Assert the packet is in stream time. - if packet.struct.stream_index < 0 or packet.struct.stream_index >= self.ptr.nb_streams: - raise ValueError('Bad Packet stream_index.') - cdef lib.AVStream *stream = self.ptr.streams[packet.struct.stream_index] - packet._rebase_time(stream.time_base) - - # Make another reference to the packet, as av_interleaved_write_frame - # takes ownership of it. - cdef lib.AVPacket packet_ref - lib.av_init_packet(&packet_ref) - self.err_check(lib.av_packet_ref(&packet_ref, &packet.struct)) - - cdef int ret - with nogil: - ret = lib.av_interleaved_write_frame(self.ptr, &packet_ref) - self.err_check(ret) diff --git a/av/container/pyio.pxd b/av/container/pyio.pxd index 1292d2c71..c1f91b7b6 100644 --- a/av/container/pyio.pxd +++ b/av/container/pyio.pxd @@ -1,8 +1,24 @@ +cimport libav as lib from libc.stdint cimport int64_t, uint8_t -cdef int pyio_read(void *opaque, uint8_t *buf, int buf_size) nogil +cdef int pyio_read(void *opaque, uint8_t *buf, int buf_size) noexcept nogil +cdef int pyio_write(void *opaque, const uint8_t *buf, int buf_size) noexcept nogil +cdef int64_t pyio_seek(void *opaque, int64_t offset, int whence) noexcept nogil +cdef int pyio_close_gil(lib.AVIOContext *pb) +cdef int pyio_close_custom_gil(lib.AVIOContext *pb) -cdef int pyio_write(void *opaque, uint8_t *buf, int buf_size) nogil +cdef class PyIOFile: + # File-like source. + cdef readonly object file + cdef object fread + cdef object fwrite + cdef object fseek + cdef object ftell + cdef object fclose -cdef int64_t pyio_seek(void *opaque, int64_t offset, int whence) nogil + # Custom IO for above. + cdef lib.AVIOContext *iocontext + cdef unsigned char *buffer + cdef int64_t pos + cdef bint pos_is_valid diff --git a/av/container/pyio.py b/av/container/pyio.py new file mode 100644 index 000000000..0f7af6eb7 --- /dev/null +++ b/av/container/pyio.py @@ -0,0 +1,204 @@ +# type: ignore +import cython +from cython import NULL +from cython.cimports import libav as lib +from cython.cimports.av.error import stash_exception +from cython.cimports.libc.stdint import int64_t, uint8_t +from cython.cimports.libc.string import memcpy + +Buf = cython.typedef(cython.pointer[uint8_t]) +BufC = cython.typedef(cython.pointer[cython.const[uint8_t]]) + +seek_func_t = cython.typedef( + "int64_t (*seek_func_t)(void *opaque, int64_t offset, int whence) noexcept nogil" +) + + +@cython.final +@cython.cclass +class PyIOFile: + def __cinit__(self, file, buffer_size, writeable=None): + self.file = file + + seek_func: seek_func_t = NULL + + readable = getattr(self.file, "readable", None) + writable = getattr(self.file, "writable", None) + seekable = getattr(self.file, "seekable", None) + self.fread = getattr(self.file, "read", None) + self.fwrite = getattr(self.file, "write", None) + self.fseek = getattr(self.file, "seek", None) + self.ftell = getattr(self.file, "tell", None) + self.fclose = getattr(self.file, "close", None) + + # To be seekable, the file object must have `seek` and `tell` methods. + # If it also has a `seekable` method, it must return True. + if ( + self.fseek is not None + and self.ftell is not None + and (seekable is None or seekable()) + ): + seek_func = pyio_seek + + if writeable is None: + writeable = self.fwrite is not None + + if writeable: + if self.fwrite is None or (writable is not None and not writable()): + raise ValueError( + "File object has no write() method, or writable() returned False." + ) + else: + if self.fread is None or (readable is not None and not readable()): + raise ValueError( + "File object has no read() method, or readable() returned False." + ) + + self.pos = 0 + self.pos_is_valid = True + + # This is effectively the maximum size of reads. + self.buffer = cython.cast(cython.p_uchar, lib.av_malloc(buffer_size)) + + self.iocontext = lib.avio_alloc_context( + self.buffer, + buffer_size, + writeable, + cython.cast(cython.p_void, self), # User data. + pyio_read, + pyio_write, + seek_func, + ) + + if seek_func: + self.iocontext.seekable = lib.AVIO_SEEKABLE_NORMAL + self.iocontext.max_packet_size = buffer_size + + def __dealloc__(self): + with cython.nogil: + # FFmpeg will not release custom input, so it's up to us to free it. + # Do not touch our original buffer as it may have been freed and replaced. + if self.iocontext: + lib.av_freep(cython.address(self.iocontext.buffer)) + lib.av_freep(cython.address(self.iocontext)) + + # We likely errored badly if we got here, and so we are still responsible. + else: + lib.av_freep(cython.address(self.buffer)) + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def pyio_read(opaque: cython.p_void, buf: Buf, buf_size: cython.int) -> cython.int: + with cython.gil: + return pyio_read_gil(opaque, buf, buf_size) + + +@cython.cfunc +@cython.exceptval(check=False) +def pyio_read_gil(opaque: cython.p_void, buf: Buf, buf_size: cython.int) -> cython.int: + self: PyIOFile + res: bytes + try: + self = cython.cast(PyIOFile, opaque) + res = self.fread(buf_size) + memcpy( + buf, cython.cast(cython.p_void, cython.cast(cython.p_char, res)), len(res) + ) + self.pos += len(res) + if not res: + return lib.AVERROR_EOF + return cython.cast(cython.int, len(res)) + except Exception: + return stash_exception() + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def pyio_write(opaque: cython.p_void, buf: BufC, buf_size: cython.int) -> cython.int: + with cython.gil: + return pyio_write_gil(opaque, buf, buf_size) + + +@cython.cfunc +@cython.exceptval(check=False) +def pyio_write_gil( + opaque: cython.p_void, buf: BufC, buf_size: cython.int +) -> cython.int: + self: PyIOFile + bytes_to_write: bytes + bytes_written: cython.int + try: + self = cython.cast(PyIOFile, opaque) + bytes_to_write = buf[:buf_size] + ret_value = self.fwrite(bytes_to_write) + bytes_written = ret_value if isinstance(ret_value, int) else buf_size + self.pos += bytes_written + return bytes_written + except Exception: + return stash_exception() + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def pyio_seek(opaque: cython.p_void, offset: int64_t, whence: cython.int) -> int64_t: + # Seek takes the standard flags, but also a ad-hoc one which means that the library + # wants to know how large the file is. We are generally allowed to ignore this. + if whence == lib.AVSEEK_SIZE: + return -1 + with cython.gil: + return pyio_seek_gil(opaque, offset, whence) + + +@cython.cfunc +def pyio_seek_gil( + opaque: cython.p_void, offset: int64_t, whence: cython.int +) -> int64_t: + self: PyIOFile + try: + self = cython.cast(PyIOFile, opaque) + res = self.fseek(offset, whence) + + # Track the position for the user. + if whence == 0: + self.pos = offset + elif whence == 1: + self.pos += offset + else: + self.pos_is_valid = False + if res is None: + if self.pos_is_valid: + res = self.pos + else: + res = self.ftell() + return res + except Exception: + return stash_exception() + + +@cython.cfunc +def pyio_close_gil(pb: cython.pointer[lib.AVIOContext]) -> cython.int: + try: + return lib.avio_close(pb) + except Exception: + return stash_exception() + + +@cython.cfunc +def pyio_close_custom_gil(pb: cython.pointer[lib.AVIOContext]) -> cython.int: + self: PyIOFile + try: + self = cython.cast(PyIOFile, pb.opaque) + + # Flush bytes in the AVIOContext buffers to the custom I/O + lib.avio_flush(pb) + + if self.fclose is not None: + self.fclose() + + return 0 + except Exception: + stash_exception() diff --git a/av/container/pyio.pyx b/av/container/pyio.pyx deleted file mode 100644 index 62629313d..000000000 --- a/av/container/pyio.pyx +++ /dev/null @@ -1,76 +0,0 @@ -from libc.string cimport memcpy -cimport libav as lib - -from av.container.core cimport Container -from av.error cimport stash_exception - - -cdef int pyio_read(void *opaque, uint8_t *buf, int buf_size) nogil: - with gil: - return pyio_read_gil(opaque, buf, buf_size) - -cdef int pyio_read_gil(void *opaque, uint8_t *buf, int buf_size): - cdef Container self - cdef bytes res - try: - self = opaque - res = self.fread(buf_size) - memcpy(buf, res, len(res)) - self.pos += len(res) - if not res: - return lib.AVERROR_EOF - return len(res) - except Exception as e: - return stash_exception() - - -cdef int pyio_write(void *opaque, uint8_t *buf, int buf_size) nogil: - with gil: - return pyio_write_gil(opaque, buf, buf_size) - -cdef int pyio_write_gil(void *opaque, uint8_t *buf, int buf_size): - cdef Container self - cdef bytes bytes_to_write - cdef int bytes_written - try: - self = opaque - bytes_to_write = buf[:buf_size] - ret_value = self.fwrite(bytes_to_write) - bytes_written = ret_value if isinstance(ret_value, int) else buf_size - self.pos += bytes_written - return bytes_written - except Exception as e: - return stash_exception() - - -cdef int64_t pyio_seek(void *opaque, int64_t offset, int whence) nogil: - # Seek takes the standard flags, but also a ad-hoc one which means that - # the library wants to know how large the file is. We are generally - # allowed to ignore this. - if whence == lib.AVSEEK_SIZE: - return -1 - with gil: - return pyio_seek_gil(opaque, offset, whence) - -cdef int64_t pyio_seek_gil(void *opaque, int64_t offset, int whence): - cdef Container self - try: - self = opaque - res = self.fseek(offset, whence) - - # Track the position for the user. - if whence == 0: - self.pos = offset - elif whence == 1: - self.pos += offset - else: - self.pos_is_valid = False - if res is None: - if self.pos_is_valid: - res = self.pos - else: - res = self.ftell() - return res - - except Exception as e: - return stash_exception() diff --git a/av/container/streams.pxd b/av/container/streams.pxd index 2ae69d84b..d62174992 100644 --- a/av/container/streams.pxd +++ b/av/container/streams.pxd @@ -1,15 +1,8 @@ -from av.stream cimport Stream +cimport libav as lib +from av.stream cimport Stream -cdef class StreamContainer(object): +cdef class StreamContainer: cdef list _streams - - # For the different types. - cdef readonly tuple video - cdef readonly tuple audio - cdef readonly tuple subtitles - cdef readonly tuple data - cdef readonly tuple other - - cdef add_stream(self, Stream stream) + cdef void add_stream(self, Stream stream) diff --git a/av/container/streams.py b/av/container/streams.py new file mode 100644 index 000000000..3b1dca00b --- /dev/null +++ b/av/container/streams.py @@ -0,0 +1,178 @@ +from collections.abc import Iterator + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.stream import Stream + + +@cython.cfunc +def _get_media_type_enum(type: str) -> lib.AVMediaType: + if type == "video": + return lib.AVMEDIA_TYPE_VIDEO + elif type == "audio": + return lib.AVMEDIA_TYPE_AUDIO + elif type == "subtitle": + return lib.AVMEDIA_TYPE_SUBTITLE + elif type == "attachment": + return lib.AVMEDIA_TYPE_ATTACHMENT + elif type == "data": + return lib.AVMEDIA_TYPE_DATA + else: + raise ValueError(f"Invalid stream type: {type}") + + +@cython.cfunc +@cython.exceptval(check=False) +def _get_best_stream_index( + fmtctx: cython.pointer[lib.AVFormatContext], + enumtype: lib.AVMediaType, + related: Stream | None, +) -> cython.int: + stream_index: cython.int + + if related is None: + stream_index = lib.av_find_best_stream(fmtctx, enumtype, -1, -1, cython.NULL, 0) + else: + stream_index = lib.av_find_best_stream( + fmtctx, enumtype, -1, related.ptr.index, cython.NULL, 0 + ) + + return stream_index + + +@cython.final +@cython.cclass +class StreamContainer: + """ + + A tuple-like container of :class:`Stream`. + + :: + + # There are a few ways to pulling out streams. + first = container.streams[0] + video = container.streams.video[0] + audio = container.streams.get(audio=(0, 1)) + + + """ + + def __cinit__(self): + self._streams = [] + + @cython.cfunc + def add_stream(self, stream: Stream) -> cython.void: + assert stream.ptr.index == len(self._streams) + self._streams.append(stream) + + # Basic tuple interface. + def __len__(self): + return len(self._streams) + + def __iter__(self): + return iter(self._streams) + + def __getitem__(self, index): + if isinstance(index, int): + return self.get(index)[0] + else: + return self.get(index) + + @property + def video(self): + return tuple(s for s in self._streams if s.type == "video") + + @property + def audio(self): + return tuple(s for s in self._streams if s.type == "audio") + + @property + def subtitles(self): + return tuple(s for s in self._streams if s.type == "subtitle") + + @property + def data(self): + return tuple(s for s in self._streams if s.type == "data") + + @property + def attachments(self): + return tuple(s for s in self._streams if s.type == "attachment") + + def _get_streams(self, x) -> Iterator[Stream]: + if x is None: + pass + elif isinstance(x, Stream): + yield x + elif isinstance(x, int): + yield self._streams[x] + elif isinstance(x, (tuple, list)): + for item in x: + yield from self._get_streams(item) + elif isinstance(x, dict): + for type_, indices in x.items(): + # For compatibility with the pseudo signature + streams = self._streams if type_ == "streams" else getattr(self, type_) + if not isinstance(indices, (tuple, list)): + indices = [indices] + for i in indices: + yield streams[i] + else: + raise TypeError("Argument must be Stream or int.", type(x)) + + def get(self, *args, **kwargs): + """get(streams=None, video=None, audio=None, subtitles=None, data=None) + + Get a selection of :class:`.Stream` as a ``list``. + + Positional arguments may be ``int`` (which is an index into the streams), + or ``list`` or ``tuple`` of those:: + + # Get the first channel. + streams.get(0) + + # Get the first two audio channels. + streams.get(audio=(0, 1)) + + Keyword arguments (or dicts as positional arguments) as interpreted + as ``(stream_type, index_value_or_set)`` pairs:: + + # Get the first video channel. + streams.get(video=0) + # or + streams.get({'video': 0}) + + :class:`.Stream` objects are passed through untouched. + + If nothing is selected, then all streams are returned. + + """ + selection = list(self._get_streams(args)) + if kwargs: + selection.extend(self._get_streams(kwargs)) + + return selection or self._streams[:] + + def best(self, type: str, /, related: Stream | None = None): + """best(type: Literal["video", "audio", "subtitle", "attachment", "data"], /, related: Stream | None) + Finds the "best" stream in the file. Wraps :ffmpeg:`av_find_best_stream`. Example:: + + stream = container.streams.best("video") + + :param type: The type of stream to find + :param related: A related stream to use as a reference (optional) + :return: The best stream of the specified type + :rtype: Stream | None + """ + if len(self._streams) == 0: + return None + + first_stream: Stream = cython.cast(Stream, self._streams[0]) + container: cython.pointer[lib.AVFormatContext] = first_stream.container.ptr + stream_index: cython.int = _get_best_stream_index( + container, _get_media_type_enum(type), related + ) + + if stream_index < 0: + return None + + return self._streams[stream_index] diff --git a/av/container/streams.pyi b/av/container/streams.pyi new file mode 100644 index 000000000..bdab1ea54 --- /dev/null +++ b/av/container/streams.pyi @@ -0,0 +1,35 @@ +from collections.abc import Iterator +from typing import Literal, overload + +from av.audio.stream import AudioStream +from av.stream import AttachmentStream, DataStream, Stream +from av.subtitles.stream import SubtitleStream +from av.video.stream import VideoStream + +class StreamContainer: + video: tuple[VideoStream, ...] + audio: tuple[AudioStream, ...] + subtitles: tuple[SubtitleStream, ...] + attachments: tuple[AttachmentStream, ...] + data: tuple[DataStream, ...] + + def __init__(self) -> None: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[Stream]: ... + @overload + def __getitem__(self, index: int) -> Stream: ... + @overload + def __getitem__(self, index: slice) -> list[Stream]: ... + @overload + def __getitem__(self, index: int | slice) -> Stream | list[Stream]: ... + def get( + self, + *args: int | Stream | dict[str, int | tuple[int, ...]], + **kwargs: int | tuple[int, ...], + ) -> list[Stream]: ... + def best( + self, + type: Literal["video", "audio", "subtitle", "data", "attachment"], + /, + related: Stream | None = None, + ) -> Stream | None: ... diff --git a/av/container/streams.pyx b/av/container/streams.pyx deleted file mode 100644 index 4ed2223d4..000000000 --- a/av/container/streams.pyx +++ /dev/null @@ -1,122 +0,0 @@ - -cimport libav as lib - - -def _flatten(input_): - for x in input_: - if isinstance(x, (tuple, list)): - for y in _flatten(x): - yield y - else: - yield x - - -cdef class StreamContainer(object): - - """ - - A tuple-like container of :class:`Stream`. - - :: - - # There are a few ways to pulling out streams. - first = container.streams[0] - video = container.streams.video[0] - audio = container.streams.get(audio=(0, 1)) - - - """ - - def __cinit__(self): - self._streams = [] - self.video = () - self.audio = () - self.subtitles = () - self.data = () - self.other = () - - cdef add_stream(self, Stream stream): - - assert stream._stream.index == len(self._streams) - self._streams.append(stream) - - if stream._codec_context.codec_type == lib.AVMEDIA_TYPE_VIDEO: - self.video = self.video + (stream, ) - elif stream._codec_context.codec_type == lib.AVMEDIA_TYPE_AUDIO: - self.audio = self.audio + (stream, ) - elif stream._codec_context.codec_type == lib.AVMEDIA_TYPE_SUBTITLE: - self.subtitles = self.subtitles + (stream, ) - elif stream._codec_context.codec_type == lib.AVMEDIA_TYPE_DATA: - self.data = self.data + (stream, ) - else: - self.other = self.other + (stream, ) - - # Basic tuple interface. - def __len__(self): - return len(self._streams) - - def __iter__(self): - return iter(self._streams) - - def __getitem__(self, index): - if isinstance(index, int): - return self.get(index)[0] - else: - return self.get(index) - - def get(self, *args, **kwargs): - """get(streams=None, video=None, audio=None, subtitles=None, data=None) - - Get a selection of :class:`.Stream` as a ``list``. - - Positional arguments may be ``int`` (which is an index into the streams), - or ``list`` or ``tuple`` of those:: - - # Get the first channel. - streams.get(0) - - # Get the first two audio channels. - streams.get(audio=(0, 1)) - - Keyword arguments (or dicts as positional arguments) as interpreted - as ``(stream_type, index_value_or_set)`` pairs:: - - # Get the first video channel. - streams.get(video=0) - # or - streams.get({'video': 0}) - - :class:`.Stream` objects are passed through untouched. - - If nothing is selected, then all streams are returned. - - """ - - selection = [] - - for x in _flatten((args, kwargs)): - - if x is None: - pass - - elif isinstance(x, Stream): - selection.append(x) - - elif isinstance(x, int): - selection.append(self._streams[x]) - - elif isinstance(x, dict): - for type_, indices in x.items(): - if type_ == 'streams': # For compatibility with the pseudo signature - streams = self._streams - else: - streams = getattr(self, type_) - if not isinstance(indices, (tuple, list)): - indices = [indices] - for i in indices: - selection.append(streams[i]) - - else: - raise TypeError('Argument must be Stream or int.', type(x)) - - return selection or self._streams[:] diff --git a/av/data/stream.pxd b/av/data/stream.pxd deleted file mode 100644 index 012792a4a..000000000 --- a/av/data/stream.pxd +++ /dev/null @@ -1,5 +0,0 @@ -from av.stream cimport Stream - - -cdef class DataStream(Stream): - pass diff --git a/av/data/stream.pyx b/av/data/stream.pyx deleted file mode 100644 index 698242c51..000000000 --- a/av/data/stream.pyx +++ /dev/null @@ -1,26 +0,0 @@ -cimport libav as lib - - -cdef class DataStream(Stream): - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.index, - self.type or '', - self.name or '', - id(self), - ) - - def encode(self, frame=None): - return [] - - def decode(self, packet=None, count=0): - return [] - - property name: - def __get__(self): - cdef const lib.AVCodecDescriptor *desc = lib.avcodec_descriptor_get(self._codec_context.codec_id) - if desc == NULL: - return None - return desc.name diff --git a/av/datasets.py b/av/datasets.py index 7ef768a2a..8547c1507 100644 --- a/av/datasets.py +++ b/av/datasets.py @@ -1,44 +1,37 @@ -from __future__ import absolute_import - import errno import logging import os +import shutil import sys - - -try: - from urllib.request import urlopen -except ImportError: - from urllib2 import urlopen - +from collections.abc import Iterator +from urllib.request import urlopen log = logging.getLogger(__name__) -def iter_data_dirs(check_writable=False): - +def iter_data_dirs(check_writable: bool = False) -> Iterator[str]: try: - yield os.environ['PYAV_TESTDATA_DIR'] + yield os.environ["PYAV_TESTDATA_DIR"] except KeyError: pass - if os.name == 'nt': - yield os.path.join(sys.prefix, 'pyav', 'datasets') + if os.name == "nt": + yield os.path.join(sys.prefix, "pyav", "datasets") return bases = [ - '/usr/local/share', - '/usr/local/lib', - '/usr/share', - '/usr/lib', + "/usr/local/share", + "/usr/local/lib", + "/usr/share", + "/usr/lib", ] # Prefer the local virtualenv. - if hasattr(sys, 'real_prefix'): + if hasattr(sys, "real_prefix"): bases.insert(0, sys.prefix) for base in bases: - dir_ = os.path.join(base, 'pyav', 'datasets') + dir_ = os.path.join(base, "pyav", "datasets") if check_writable: if os.path.exists(dir_): if not os.access(dir_, os.W_OK): @@ -48,11 +41,10 @@ def iter_data_dirs(check_writable=False): continue yield dir_ - yield os.path.join(os.path.expanduser('~'), '.pyav', 'datasets') - + yield os.path.join(os.path.expanduser("~"), ".pyav", "datasets") -def cached_download(url, name): +def cached_download(url: str, name: str) -> str: """Download the data at a URL, and cache it under the given name. The file is stored under `pyav/test` with the given name in the directory @@ -69,7 +61,7 @@ def cached_download(url, name): clean_name = os.path.normpath(name) if clean_name != name: - raise ValueError("{} is not normalized.".format(name)) + raise ValueError(f"{name} is not normalized.") for dir_ in iter_data_dirs(): path = os.path.join(dir_, name) @@ -79,11 +71,11 @@ def cached_download(url, name): dir_ = next(iter_data_dirs(True)) path = os.path.join(dir_, name) - log.info("Downloading {} to {}".format(url, path)) + log.info(f"Downloading {url} to {path}") response = urlopen(url) if response.getcode() != 200: - raise ValueError("HTTP {}".format(response.getcode())) + raise ValueError(f"HTTP {response.getcode()}") dir_ = os.path.dirname(path) try: @@ -92,21 +84,16 @@ def cached_download(url, name): if e.errno != errno.EEXIST: raise - tmp_path = path + '.tmp' - with open(tmp_path, 'wb') as fh: - while True: - chunk = response.read(8196) - if chunk: - fh.write(chunk) - else: - break + tmp_path = path + ".tmp" + with open(tmp_path, "wb") as fh: + shutil.copyfileobj(response, fh) os.rename(tmp_path, path) return path -def fate(name): +def fate(name: str) -> str: """Download and return a path to a sample from the FFmpeg test suite. Data is handled by :func:`cached_download`. @@ -114,15 +101,19 @@ def fate(name): See the `FFmpeg Automated Test Environment `_ """ - return cached_download('http://fate.ffmpeg.org/fate-suite/' + name, - os.path.join('fate-suite', name.replace('/', os.path.sep))) + return cached_download( + "http://fate.ffmpeg.org/fate-suite/" + name, + os.path.join("fate-suite", name.replace("/", os.path.sep)), + ) -def curated(name): +def curated(name: str) -> str: """Download and return a path to a sample that is curated by the PyAV developers. Data is handled by :func:`cached_download`. """ - return cached_download('https://pyav.org/datasets/' + name, - os.path.join('pyav-curated', name.replace('/', os.path.sep))) + return cached_download( + "https://pyav.org/datasets/" + name, + os.path.join("pyav-curated", name.replace("/", os.path.sep)), + ) diff --git a/av/deprecation.py b/av/deprecation.py deleted file mode 100644 index b62723ade..000000000 --- a/av/deprecation.py +++ /dev/null @@ -1,69 +0,0 @@ -import functools -import warnings - - -class AVDeprecationWarning(DeprecationWarning): - pass - - -class AttributeRenamedWarning(AVDeprecationWarning): - pass - - -class MethodDeprecationWarning(AVDeprecationWarning): - pass - - -# DeprecationWarning is not printed by default (unless in __main__). We -# really want these to be seen, but also to use the "correct" base classes. -# So we're putting a filter in place to show our warnings. The users can -# turn them back off if they want. -warnings.filterwarnings('default', '', AVDeprecationWarning) - - -class renamed_attr(object): - - """Proxy for renamed attributes (or methods) on classes. - Getting and setting values will be redirected to the provided name, - and warnings will be issues every time. - - """ - - def __init__(self, new_name): - self.new_name = new_name - self._old_name = None - - def old_name(self, cls): - if self._old_name is None: - for k, v in vars(cls).items(): - if v is self: - self._old_name = k - break - return self._old_name - - def __get__(self, instance, cls): - old_name = self.old_name(cls) - warnings.warn('{0}.{1} is deprecated; please use {0}.{2}.'.format( - cls.__name__, old_name, self.new_name, - ), AttributeRenamedWarning, stacklevel=2) - return getattr(instance if instance is not None else cls, self.new_name) - - def __set__(self, instance, value): - old_name = self.old_name(instance.__class__) - warnings.warn('{0}.{1} is deprecated; please use {0}.{2}.'.format( - instance.__class__.__name__, old_name, self.new_name, - ), AttributeRenamedWarning, stacklevel=2) - setattr(instance, self.new_name, value) - - -class method(object): - - def __init__(self, func): - functools.update_wrapper(self, func, ('__name__', '__doc__')) - self.func = func - - def __get__(self, instance, cls): - warning = MethodDeprecationWarning('{}.{} is deprecated.'.format( - cls.__name__, self.func.__name__)) - warnings.warn(warning, stacklevel=2) - return self.func.__get__(instance, cls) diff --git a/av/descriptor.pxd b/av/descriptor.pxd deleted file mode 100644 index 98b039c5d..000000000 --- a/av/descriptor.pxd +++ /dev/null @@ -1,20 +0,0 @@ -cimport libav as lib - - -cdef class Descriptor(object): - - # These are present as: - # - AVCodecContext.av_class (same as avcodec_get_class()) - # - AVFormatContext.av_class (same as avformat_get_class()) - # - AVFilterContext.av_class (same as avfilter_get_class()) - # - AVCodec.priv_class - # - AVOutputFormat.priv_class - # - AVInputFormat.priv_class - # - AVFilter.priv_class - - cdef const lib.AVClass *ptr - - cdef object _options # Option list cache. - - -cdef Descriptor wrap_avclass(const lib.AVClass*) diff --git a/av/descriptor.pyx b/av/descriptor.pyx deleted file mode 100644 index d945b0ac6..000000000 --- a/av/descriptor.pyx +++ /dev/null @@ -1,59 +0,0 @@ -cimport libav as lib - -from .option cimport Option, OptionChoice, wrap_option, wrap_option_choice - - -cdef object _cinit_sentinel = object() - -cdef Descriptor wrap_avclass(const lib.AVClass *ptr): - if ptr == NULL: - return None - cdef Descriptor obj = Descriptor(_cinit_sentinel) - obj.ptr = ptr - return obj - - -cdef class Descriptor(object): - - def __cinit__(self, sentinel): - if sentinel is not _cinit_sentinel: - raise RuntimeError('Cannot construct av.Descriptor') - - property name: - def __get__(self): return self.ptr.class_name if self.ptr.class_name else None - - property options: - def __get__(self): - cdef const lib.AVOption *ptr = self.ptr.option - cdef const lib.AVOption *choice_ptr - cdef Option option - cdef OptionChoice option_choice - cdef bint choice_is_default - if self._options is None: - options = [] - ptr = self.ptr.option - while ptr != NULL and ptr.name != NULL: - if ptr.type == lib.AV_OPT_TYPE_CONST: - ptr += 1 - continue - choices = [] - if ptr.unit != NULL: # option has choices (matching const options) - choice_ptr = self.ptr.option - while choice_ptr != NULL and choice_ptr.name != NULL: - if choice_ptr.type != lib.AV_OPT_TYPE_CONST or choice_ptr.unit != ptr.unit: - choice_ptr += 1 - continue - choice_is_default = (choice_ptr.default_val.i64 == ptr.default_val.i64 or - ptr.type == lib.AV_OPT_TYPE_FLAGS and - choice_ptr.default_val.i64 & ptr.default_val.i64) - option_choice = wrap_option_choice(choice_ptr, choice_is_default) - choices.append(option_choice) - choice_ptr += 1 - option = wrap_option(tuple(choices), ptr) - options.append(option) - ptr += 1 - self._options = tuple(options) - return self._options - - def __repr__(self): - return '<%s %s at 0x%x>' % (self.__class__.__name__, self.name, id(self)) diff --git a/av/device.py b/av/device.py new file mode 100644 index 000000000..b5d5d110c --- /dev/null +++ b/av/device.py @@ -0,0 +1,187 @@ +import re + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.error import err_check + + +class DeviceInfo: + """Information about an input or output device. + + :param str name: The device identifier, for use as the first argument to :func:`av.open`. + :param str description: Human-readable description of the device. + :param bool is_default: Whether this is the default device. + :param list media_types: Media types this device provides, e.g. ``["video"]``, ``["audio"]``, + or ``["video", "audio"]``. + + """ + + name: str + description: str + is_default: bool + media_types: list[str] + + def __init__( + self, + name: str, + description: str, + is_default: bool, + media_types: list[str], + ) -> None: + self.name = name + self.description = description + self.is_default = is_default + self.media_types = media_types + + def __repr__(self) -> str: + default = " (default)" if self.is_default else "" + return f"" + + +@cython.cfunc +def _build_device_list(device_list: cython.pointer[lib.AVDeviceInfoList]) -> list: + devices: list = [] + i: cython.int + j: cython.int + device_info: cython.pointer[lib.AVDeviceInfo] + mt: lib.AVMediaType + s: cython.p_const_char + + for i in range(device_list.nb_devices): + device_info = device_list.devices[i] + + media_types: list = [] + for j in range(device_info.nb_media_types): + mt = device_info.media_types[j] + s = lib.av_get_media_type_string(mt) + if s: + media_types.append(s) + + devices.append( + DeviceInfo( + name=cython.cast(bytes, device_info.device_name).decode() + if device_info.device_name + else "", + description=cython.cast(bytes, device_info.device_description).decode() + if device_info.device_description + else "", + is_default=(i == device_list.default_device), + media_types=media_types, + ) + ) + + return devices + + +def _enumerate_via_log_fallback(format_name: str) -> list[DeviceInfo]: + """Fallback for formats (e.g. avfoundation) that log devices instead of + implementing get_device_list. Opens the format with list_devices=1 and + parses the INFO log output.""" + from av import logging as avlogging + + fmt: cython.pointer[cython.const[lib.AVInputFormat]] = lib.av_find_input_format( + format_name + ) + + opts: cython.pointer[lib.AVDictionary] = cython.NULL + lib.av_dict_set(cython.address(opts), b"list_devices", b"1", 0) + + ctx: cython.pointer[lib.AVFormatContext] = cython.NULL + + # Temporarily enable INFO logging so Capture receives device list messages. + old_level = avlogging.get_level() + avlogging.set_level(avlogging.INFO) + devices: list[DeviceInfo] = [] + try: + with avlogging.Capture() as logs: + lib.avformat_open_input(cython.address(ctx), b"", fmt, cython.address(opts)) + if ctx: + lib.avformat_close_input(cython.address(ctx)) + + current_media_type = "video" + for _level, _name, message in logs: + message = message.strip() + if "video devices" in message.lower(): + current_media_type = "video" + elif "audio devices" in message.lower(): + current_media_type = "audio" + else: + m = re.match(r"\[(\d+)\] (.+)", message) + if m: + devices.append( + DeviceInfo( + name=m.group(1), + description=m.group(2), + is_default=False, + media_types=[current_media_type], + ) + ) + finally: + avlogging.set_level(old_level) + lib.av_dict_free(cython.address(opts)) + + return devices + + +def enumerate_input_devices(format_name: str) -> list[DeviceInfo]: + """List the available input devices for a given format. + + :param str format_name: The format name, e.g. ``"avfoundation"``, ``"dshow"``, ``"v4l2"``. + :rtype: list[DeviceInfo] + :raises ValueError: If *format_name* is not a known input format. + :raises av.FFmpegError: If the device does not support enumeration. + + Example:: + + for device in av.enumerate_input_devices("avfoundation"): + print(device.name, device.description) + + """ + fmt: cython.pointer[cython.const[lib.AVInputFormat]] = lib.av_find_input_format( + format_name + ) + if not fmt: + raise ValueError(f"no such input format: {format_name!r}") + + device_list: cython.pointer[lib.AVDeviceInfoList] = cython.NULL + try: + err_check( + lib.avdevice_list_input_sources( + fmt, cython.NULL, cython.NULL, cython.address(device_list) + ) + ) + return _build_device_list(device_list) + except NotImplementedError: + # Format doesn't implement get_device_list (e.g. avfoundation). + # Fall back to opening with list_devices=1 and parsing the log output. + return _enumerate_via_log_fallback(format_name) + finally: + lib.avdevice_free_list_devices(cython.address(device_list)) + + +def enumerate_output_devices(format_name: str) -> list[DeviceInfo]: + """List the available output devices for a given format. + + :param str format_name: The format name, e.g. ``"audiotoolbox"``. + :rtype: list[DeviceInfo] + :raises ValueError: If *format_name* is not a known output format. + :raises av.FFmpegError: If the device does not support enumeration. + + """ + fmt: cython.pointer[cython.const[lib.AVOutputFormat]] = lib.av_guess_format( + format_name, cython.NULL, cython.NULL + ) + if not fmt: + raise ValueError(f"no such output format: {format_name!r}") + + device_list: cython.pointer[lib.AVDeviceInfoList] = cython.NULL + err_check( + lib.avdevice_list_output_sinks( + fmt, cython.NULL, cython.NULL, cython.address(device_list) + ) + ) + + try: + return _build_device_list(device_list) + finally: + lib.avdevice_free_list_devices(cython.address(device_list)) diff --git a/av/device.pyi b/av/device.pyi new file mode 100644 index 000000000..33c556994 --- /dev/null +++ b/av/device.pyi @@ -0,0 +1,19 @@ +__all__ = ("DeviceInfo", "enumerate_input_devices", "enumerate_output_devices") + +class DeviceInfo: + name: str + description: str + is_default: bool + media_types: list[str] + + def __init__( + self, + name: str, + description: str, + is_default: bool, + media_types: list[str], + ) -> None: ... + def __repr__(self) -> str: ... + +def enumerate_input_devices(format_name: str) -> list[DeviceInfo]: ... +def enumerate_output_devices(format_name: str) -> list[DeviceInfo]: ... diff --git a/av/dictionary.pxd b/av/dictionary.pxd index 84cb24068..d1eb3c9d1 100644 --- a/av/dictionary.pxd +++ b/av/dictionary.pxd @@ -1,11 +1,8 @@ cimport libav as lib -cdef class _Dictionary(object): - +cdef class Dictionary: cdef lib.AVDictionary *ptr + cpdef Dictionary copy(self) - cpdef _Dictionary copy(self) - - -cdef _Dictionary wrap_dictionary(lib.AVDictionary *input_) +cdef Dictionary wrap_dictionary(lib.AVDictionary *input_) diff --git a/av/dictionary.py b/av/dictionary.py new file mode 100644 index 000000000..c9bd76acb --- /dev/null +++ b/av/dictionary.py @@ -0,0 +1,78 @@ +import cython +from cython.cimports.av.error import err_check + + +@cython.final +@cython.cclass +class Dictionary: + def __cinit__(self, *args, **kwargs): + for arg in args: + self.update(arg) + if kwargs: + self.update(kwargs) + + def __dealloc__(self): + if self.ptr != cython.NULL: + lib.av_dict_free(cython.address(self.ptr)) + + def __getitem__(self, key: cython.str): + element: cython.pointer[lib.AVDictionaryEntry] = lib.av_dict_get( + self.ptr, key, cython.NULL, 0 + ) + if element == cython.NULL: + raise KeyError(key) + return element.value + + def __setitem__(self, key: cython.str, value: cython.str): + err_check(lib.av_dict_set(cython.address(self.ptr), key, value, 0)) + + def __delitem__(self, key: cython.str): + err_check(lib.av_dict_set(cython.address(self.ptr), key, cython.NULL, 0)) + + def __len__(self): + return err_check(lib.av_dict_count(self.ptr)) + + def __iter__(self): + element: cython.pointer[lib.AVDictionaryEntry] = cython.NULL + while True: + element = lib.av_dict_get(self.ptr, "", element, lib.AV_DICT_IGNORE_SUFFIX) + if element == cython.NULL: + break + yield element.key + + def __repr__(self): + return f"av.Dictionary({dict(self)!r})" + + def keys(self): + return list(self) + + def copy(self): + other: Dictionary = Dictionary() + lib.av_dict_copy(cython.address(other.ptr), self.ptr, 0) + return other + + def pop(self, key: str): + value = self[key] + del self[key] + return value + + def update(self, other=(), /, **kwds): + if isinstance(other, Dictionary): + lib.av_dict_copy( + cython.address(self.ptr), cython.cast(Dictionary, other).ptr, 0 + ) + elif hasattr(other, "keys"): + for key in other.keys(): + self[key] = other[key] + else: + for key, value in other: + self[key] = value + for key, value in kwds.items(): + self[key] = value + + +@cython.cfunc +def wrap_dictionary(input_: cython.pointer[lib.AVDictionary]) -> Dictionary: + output: Dictionary = Dictionary() + output.ptr = input_ + return output diff --git a/av/dictionary.pyi b/av/dictionary.pyi new file mode 100644 index 000000000..f68003c6a --- /dev/null +++ b/av/dictionary.pyi @@ -0,0 +1,18 @@ +from collections.abc import Iterable, Iterator, Mapping + +class Dictionary: + def __getitem__(self, key: str) -> str: ... + def __setitem__(self, key: str, value: str) -> None: ... + def __delitem__(self, key: str) -> None: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[str]: ... + def __repr__(self) -> str: ... + def keys(self) -> Iterable[str]: ... + def copy(self) -> Dictionary: ... + def pop(self, key: str) -> str: ... + def update( + self, + other: Mapping[str, str] | Iterable[tuple[str, str]] = (), + /, + **kwds: str, + ) -> None: ... diff --git a/av/dictionary.pyx b/av/dictionary.pyx deleted file mode 100644 index 6cc199612..000000000 --- a/av/dictionary.pyx +++ /dev/null @@ -1,61 +0,0 @@ -try: - from collections.abc import MutableMapping -except ImportError: - from collections import MutableMapping - -from av.error cimport err_check - - -cdef class _Dictionary(object): - - def __cinit__(self, *args, **kwargs): - for arg in args: - self.update(arg) - if kwargs: - self.update(kwargs) - - def __dealloc__(self): - if self.ptr != NULL: - lib.av_dict_free(&self.ptr) - - def __getitem__(self, str key): - cdef lib.AVDictionaryEntry *element = lib.av_dict_get(self.ptr, key, NULL, 0) - if element != NULL: - return element.value - else: - raise KeyError(key) - - def __setitem__(self, str key, str value): - err_check(lib.av_dict_set(&self.ptr, key, value, 0)) - - def __delitem__(self, str key): - err_check(lib.av_dict_set(&self.ptr, key, NULL, 0)) - - def __len__(self): - return err_check(lib.av_dict_count(self.ptr)) - - def __iter__(self): - cdef lib.AVDictionaryEntry *element = NULL - while True: - element = lib.av_dict_get(self.ptr, "", element, lib.AV_DICT_IGNORE_SUFFIX) - if element == NULL: - break - yield element.key - - def __repr__(self): - return 'av.Dictionary(%r)' % dict(self) - - cpdef _Dictionary copy(self): - cdef _Dictionary other = Dictionary() - lib.av_dict_copy(&other.ptr, self.ptr, 0) - return other - - -class Dictionary(_Dictionary, MutableMapping): - pass - - -cdef _Dictionary wrap_dictionary(lib.AVDictionary *input_): - cdef _Dictionary output = Dictionary() - output.ptr = input_ - return output diff --git a/av/enum.pxd b/av/enum.pxd deleted file mode 100644 index 884fdd961..000000000 --- a/av/enum.pxd +++ /dev/null @@ -1,4 +0,0 @@ -cpdef define_enum( - name, module, items, - bint is_flags=* -) diff --git a/av/enum.pyx b/av/enum.pyx deleted file mode 100644 index e54bf67ea..000000000 --- a/av/enum.pyx +++ /dev/null @@ -1,394 +0,0 @@ -""" - -PyAV provides enumeration and flag classes that are similar to the stdlib ``enum`` -module that shipped with Python 3.4. - -PyAV's enums are a little more forgiving to preserve backwards compatibility -with earlier PyAV patterns. e.g., they can be freely compared to strings or -integers for names and values respectively. - -""" - -from collections import OrderedDict -import sys - - -try: - import copyreg -except ImportError: - import copy_reg as copyreg - - -cdef sentinel = object() - - -class EnumType(type): - - def __new__(mcl, name, bases, attrs, *args): - # Just adapting the method signature. - return super().__new__(mcl, name, bases, attrs) - - def __init__(self, name, bases, attrs, items): - - self._by_name = {} - self._by_value = {} - self._all = [] - - for spec in items: - self._create(*spec) - - def _create(self, name, value, doc=None, by_value_only=False): - - # We only have one instance per value. - try: - item = self._by_value[value] - except KeyError: - item = self(sentinel, name, value, doc) - self._by_value[value] = item - - if not by_value_only: - setattr(self, name, item) - self._all.append(item) - self._by_name[name] = item - - return item - - def __len__(self): - return len(self._all) - - def __iter__(self): - return iter(self._all) - - def __getitem__(self, key): - - if isinstance(key, str): - return self._by_name[key] - - if isinstance(key, int): - try: - return self._by_value[key] - except KeyError: - pass - - if issubclass(self, EnumFlag): - return self._get_multi_flags(key) - - raise KeyError(key) - - if isinstance(key, self): - return key - - raise TypeError("{0} indices must be str, int, or {0}".format( - self.__name__, - )) - - def _get(self, long value, bint create=False): - - try: - return self._by_value[value] - except KeyError: - pass - - if not create: - return - - return self._create('{}_{}'.format(self.__name__.upper(), value), value, by_value_only=True) - - def _get_multi_flags(self, long value): - - try: - return self._by_value[value] - except KeyError: - pass - - flags = [] - cdef long to_find = value - for item in self: - if item.value & to_find: - flags.append(item) - to_find = to_find ^ item.value - if not to_find: - break - - if to_find: - raise KeyError(value) - - name = '|'.join(f.name for f in flags) - cdef EnumFlag combo = self._create(name, value, by_value_only=True) - combo.flags = tuple(flags) - - return combo - - def get(self, key, default=None, create=False): - try: - return self[key] - except KeyError: - if create: - return self._get(key, create=True) - return default - - def property(self, *args, **kwargs): - return EnumProperty(self, *args, **kwargs) - - -def _unpickle(mod_name, cls_name, item_name): - mod = __import__(mod_name, fromlist=['.']) - cls = getattr(mod, cls_name) - return cls[item_name] - - -copyreg.constructor(_unpickle) - - -cdef class EnumItem(object): - - """ - Enumerations are when an attribute may only take on a single value at once, and - they are represented as integers in the FFmpeg API. We associate names with each - value that are easier to operate with. - - Consider :data:`av.codec.context.SkipType`, which is the type of the :attr:`CodecContext.skip_frame` attribute:: - - >>> fh = av.open(video_path) - >>> cc = fh.streams.video[0].codec_context - - >>> # The skip_frame attribute has a name and value: - >>> cc.skip_frame.name - 'DEFAULT' - >>> cc.skip_frame.value - 0 - - >>> # You can compare it to strings and ints: - >>> cc.skip_frame == 'DEFAULT' - True - >>> cc.skip_frame == 0 - True - - >>> # You can assign strings and ints: - >>> cc.skip_frame = 'NONKEY' - >>> cc.skip_frame == 'NONKEY' - True - >>> cc.skip_frame == 32 - True - - """ - - cdef readonly str name - cdef readonly int value - cdef Py_hash_t _hash - - def __cinit__(self, sentinel_, str name, int value, doc=None): - - if sentinel_ is not sentinel: - raise RuntimeError("Cannot instantiate {}.".format(self.__class__.__name__)) - - self.name = name - self.value = value - self.__doc__ = doc # This is not cdef because it doesn't work if it is. - - # We need to establish a hash that doesn't collide with anything that - # would return true from `__eq__`. This is because these enums (vs - # the stdlib ones) are weakly typed (they will compare against string - # names and int values), and if we have the same hash AND are equal, - # then they will be equivalent as keys in a dictionary, which is wierd. - cdef Py_hash_t hash_ = value + 1 - if hash_ == hash(name): - hash_ += 1 - self._hash = hash_ - - def __repr__(self): - return '<{}.{}:{}(0x{:x})>'.format( - self.__class__.__module__, - self.__class__.__name__, - self.name, - self.value, - ) - - def __str__(self): - return self.name - - def __int__(self): - return self.value - - def __hash__(self): - return self._hash - - def __reduce__(self): - return (_unpickle, (self.__class__.__module__, self.__class__.__name__, self.name)) - - def __eq__(self, other): - - if isinstance(other, str): - - if self.name == other: # The quick method. - return True - - try: - other_inst = self.__class__._by_name[other] - except KeyError: - raise ValueError("{} does not have item named {!r}".format( - self.__class__.__name__, - other, - )) - else: - return self is other_inst - - if isinstance(other, int): - if self.value == other: - return True - if other in self.__class__._by_value: - return False - raise ValueError("{} does not have item valued {}".format( - self.__class__.__name__, - other, - )) - - if isinstance(other, self.__class__): - return self is other - - raise TypeError("'==' not supported between {} and {}".format( - self.__class__.__name__, - type(other).__name__, - )) - - def __ne__(self, other): - return not (self == other) - - -cdef class EnumFlag(EnumItem): - - """ - Flags are sets of boolean attributes, which the FFmpeg API represents as individual - bits in a larger integer which you manipulate with the bitwise operators. - We associate names with each flag that are easier to operate with. - - Consider :data:`CodecContextFlags`, whis is the type of the :attr:`CodecContext.flags` - attribute, and the set of boolean properties:: - - >>> fh = av.open(video_path) - >>> cc = fh.streams.video[0].codec_context - - >>> cc.flags - - - >>> # You can set flags via bitwise operations with the objects, names, or values: - >>> cc.flags |= cc.flags.OUTPUT_CORRUPT - >>> cc.flags |= 'GLOBAL_HEADER' - >>> cc.flags - - - >>> # You can test flags via bitwise operations with objects, names, or values: - >>> bool(cc.flags & cc.flags.OUTPUT_CORRUPT) - True - >>> bool(cc.flags & 'QSCALE') - False - - >>> # There are boolean properties for each flag: - >>> cc.output_corrupt - True - >>> cc.qscale - False - - >>> # You can set them: - >>> cc.qscale = True - >>> cc.flags - - - """ - - cdef readonly tuple flags - - def __cinit__(self, sentinel, name, value, doc=None): - self.flags = (self, ) - - def __and__(self, other): - if not isinstance(other, int): - other = self.__class__[other].value - value = self.value & other - return self.__class__._get_multi_flags(value) - - def __or__(self, other): - if not isinstance(other, int): - other = self.__class__[other].value - value = self.value | other - return self.__class__._get_multi_flags(value) - - def __xor__(self, other): - if not isinstance(other, int): - other = self.__class__[other].value - value = self.value ^ other - return self.__class__._get_multi_flags(value) - - def __invert__(self): - # This can't result in a flag, but is helpful. - return ~self.value - - def __nonzero__(self): - return bool(self.value) - - -cdef class EnumProperty(object): - - cdef object enum - cdef object fget - cdef object fset - cdef public __doc__ - - def __init__(self, enum, fget, fset=None, doc=None): - self.enum = enum - self.fget = fget - self.fset = fset - self.__doc__ = doc or fget.__doc__ - - def setter(self, fset): - self.fset = fset - return self - - def __get__(self, inst, owner): - if inst is not None: - value = self.fget(inst) - return self.enum.get(value, create=True) - else: - return self - - def __set__(self, inst, value): - item = self.enum.get(value) - self.fset(inst, item.value) - - def flag_property(self, name, doc=None): - - item = self.enum[name] - cdef int item_value = item.value - - class Property(property): - pass - - @Property - def _property(inst): - return bool(self.fget(inst) & item_value) - - if self.fset: - @_property.setter - def _property(inst, value): - if value: - flags = self.fget(inst) | item_value - else: - flags = self.fget(inst) & ~item_value - self.fset(inst, flags) - - _property.__doc__ = doc or item.__doc__ - _property._enum_item = item - - return _property - - -cpdef define_enum(name, module, items, bint is_flags=False): - - if is_flags: - base_cls = EnumFlag - else: - base_cls = EnumItem - - cls = EnumType(name, (base_cls, ), {'__module__': module}, items) - - return cls diff --git a/av/error.pxd b/av/error.pxd index 836300d22..17c37f609 100644 --- a/av/error.pxd +++ b/av/error.pxd @@ -1,5 +1,2 @@ - cdef int stash_exception(exc_info=*) - cpdef int err_check(int res, filename=*) except -1 -cpdef make_error(int res, filename=*, log=*) diff --git a/av/error.py b/av/error.py new file mode 100644 index 000000000..941dfcbb8 --- /dev/null +++ b/av/error.py @@ -0,0 +1,356 @@ +import enum +import errno +import os +import sys +import traceback +from threading import local + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.logging import get_last_error +from cython.cimports.libc.stdio import fprintf, stderr +from cython.cimports.libc.stdlib import free, malloc + +# Will get extended with all the exceptions. +__all__ = [ + "ErrorType", + "FFmpegError", + "LookupError", + "HTTPError", + "HTTPClientError", + "UndefinedError", +] + + +@cython.ccall +def code_to_tag(code: cython.int) -> bytes: + """Convert an integer error code into 4-byte tag. + + >>> code_to_tag(1953719668) + b'test' + + """ + return bytes( + ( + code & 0xFF, + (code >> 8) & 0xFF, + (code >> 16) & 0xFF, + (code >> 24) & 0xFF, + ) + ) + + +@cython.ccall +def tag_to_code(tag: bytes) -> cython.int: + """Convert a 4-byte error tag into an integer code. + + >>> tag_to_code(b'test') + 1953719668 + + """ + if len(tag) != 4: + raise ValueError("Error tags are 4 bytes.") + return (tag[0]) + (tag[1] << 8) + (tag[2] << 16) + (tag[3] << 24) + + +class FFmpegError(Exception): + """Exception class for errors from within FFmpeg. + + .. attribute:: errno + + FFmpeg's integer error code. + + .. attribute:: strerror + + FFmpeg's error message. + + .. attribute:: filename + + The filename that was being operated on (if available). + + .. attribute:: log + + The tuple from :func:`av.logging.get_last_log`, or ``None``. + + """ + + def __init__(self, code, message, filename=None, log=None): + args = [code, message] + if filename or log: + args.append(filename) + if log: + args.append(log) + super().__init__(*args) + + self.errno = code + self.strerror = message + self.args = tuple(args) + + @property + def filename(self): + try: + return self.args[2] + except IndexError: + pass + + @property + def log(self): + try: + return self.args[3] + except IndexError: + pass + + def __str__(self): + msg = "" + if self.errno is not None: + msg = f"{msg}[Errno {self.errno}] " + if self.strerror is not None: + msg = f"{msg}{self.strerror}" + if self.filename: + msg = f"{msg}: {self.filename!r}" + if self.log: + msg = ( + f"{msg}; last error log: [{self.log[1].strip()}] {self.log[2].strip()}" + ) + + return msg + + +# Our custom error, used in callbacks. +c_PYAV_STASHED_ERROR = cython.declare(cython.int, tag_to_code(b"PyAV")) +PYAV_STASHED_ERROR_message = cython.declare(str, "Error in PyAV callback") + + +# Bases for the FFmpeg-based exceptions. +class LookupError(FFmpegError, LookupError): + pass + + +class HTTPError(FFmpegError): + pass + + +class HTTPClientError(FFmpegError): + pass + + +# Tuples of (enum_name, enum_value, exc_name, exc_base). +# tuple[str, int, str | None, Exception | none] +# fmt: off +_ffmpeg_specs = ( + ("BSF_NOT_FOUND", -lib.AVERROR_BSF_NOT_FOUND, "BSFNotFoundError", LookupError), + ("BUG", -lib.AVERROR_BUG, None, RuntimeError), + ("BUFFER_TOO_SMALL", -lib.AVERROR_BUFFER_TOO_SMALL, None, ValueError), + ("DECODER_NOT_FOUND", -lib.AVERROR_DECODER_NOT_FOUND, None, LookupError), + ("DEMUXER_NOT_FOUND", -lib.AVERROR_DEMUXER_NOT_FOUND, None, LookupError), + ("ENCODER_NOT_FOUND", -lib.AVERROR_ENCODER_NOT_FOUND, None, LookupError), + ("EOF", -lib.AVERROR_EOF, "EOFError", EOFError), + ("EXIT", -lib.AVERROR_EXIT, None, None), + ("EXTERNAL", -lib.AVERROR_EXTERNAL, None, None), + ("FILTER_NOT_FOUND", -lib.AVERROR_FILTER_NOT_FOUND, None, LookupError), + ("INVALIDDATA", -lib.AVERROR_INVALIDDATA, "InvalidDataError", ValueError), + ("MUXER_NOT_FOUND", -lib.AVERROR_MUXER_NOT_FOUND, None, LookupError), + ("OPTION_NOT_FOUND", -lib.AVERROR_OPTION_NOT_FOUND, None, LookupError), + ("PATCHWELCOME", -lib.AVERROR_PATCHWELCOME, "PatchWelcomeError", None), + ("PROTOCOL_NOT_FOUND", -lib.AVERROR_PROTOCOL_NOT_FOUND, None, LookupError), + ("UNKNOWN", -lib.AVERROR_UNKNOWN, None, None), + ("EXPERIMENTAL", -lib.AVERROR_EXPERIMENTAL, None, None), + ("INPUT_CHANGED", -lib.AVERROR_INPUT_CHANGED, None, None), + ("OUTPUT_CHANGED", -lib.AVERROR_OUTPUT_CHANGED, None, None), + ("HTTP_BAD_REQUEST", -lib.AVERROR_HTTP_BAD_REQUEST, "HTTPBadRequestError", HTTPClientError), + ("HTTP_UNAUTHORIZED", -lib.AVERROR_HTTP_UNAUTHORIZED, "HTTPUnauthorizedError", HTTPClientError), + ("HTTP_FORBIDDEN", -lib.AVERROR_HTTP_FORBIDDEN, "HTTPForbiddenError", HTTPClientError), + ("HTTP_NOT_FOUND", -lib.AVERROR_HTTP_NOT_FOUND, "HTTPNotFoundError", HTTPClientError), + ("HTTP_TOO_MANY_REQUESTS", -lib.AVERROR_HTTP_TOO_MANY_REQUESTS, "HTTPTooManyRequestsError", HTTPClientError), + ("HTTP_OTHER_4XX", -lib.AVERROR_HTTP_OTHER_4XX, "HTTPOtherClientError", HTTPClientError), + ("HTTP_SERVER_ERROR", -lib.AVERROR_HTTP_SERVER_ERROR, "HTTPServerError", HTTPError), + ("PYAV_CALLBACK", c_PYAV_STASHED_ERROR, "PyAVCallbackError", RuntimeError), +) +# fmt: on + + +ErrorType = enum.IntEnum( + "ErrorType", + [(name, value) for name, value, *_ in _ffmpeg_specs], + module=__name__, +) +ErrorType.__doc__ = "An enumeration of FFmpeg's error types." + + +def _error_type_tag(self) -> bytes: + """The FFmpeg byte tag for the error.""" + return code_to_tag(self.value) + + +def _error_type_strerror(self) -> str: + """The error message that would be returned.""" + if self.value == c_PYAV_STASHED_ERROR: + return PYAV_STASHED_ERROR_message + return lib.av_err2str(-self.value) + + +ErrorType.tag = property(_error_type_tag) +ErrorType.strerror = property(_error_type_strerror) + +classes: dict = {} + + +def _extend_builtin(name, codes): + base = getattr(__builtins__, name, OSError) + cls = type(name, (FFmpegError, base), {"__module__": __name__}) + + # Register in builder. + for code in codes: + classes[code] = cls + + # Register in module. + globals()[name] = cls + __all__.append(name) + + return cls + + +_extend_builtin("PermissionError", (errno.EACCES, errno.EPERM)) +_extend_builtin( + "BlockingIOError", + (errno.EAGAIN, errno.EALREADY, errno.EINPROGRESS, errno.EWOULDBLOCK), +) +_extend_builtin("ChildProcessError", (errno.ECHILD,)) +_extend_builtin("ConnectionAbortedError", (errno.ECONNABORTED,)) +_extend_builtin("ConnectionRefusedError", (errno.ECONNREFUSED,)) +_extend_builtin("ConnectionResetError", (errno.ECONNRESET,)) +_extend_builtin("FileExistsError", (errno.EEXIST,)) +_extend_builtin("InterruptedError", (errno.EINTR,)) +_extend_builtin("IsADirectoryError", (errno.EISDIR,)) +_extend_builtin("FileNotFoundError", (errno.ENOENT,)) +_extend_builtin("NotADirectoryError", (errno.ENOTDIR,)) +_extend_builtin("BrokenPipeError", (errno.EPIPE, errno.ESHUTDOWN)) +_extend_builtin("ProcessLookupError", (errno.ESRCH,)) +_extend_builtin("TimeoutError", (errno.ETIMEDOUT,)) +_extend_builtin("MemoryError", (errno.ENOMEM,)) +_extend_builtin("NotImplementedError", (errno.ENOSYS,)) +_extend_builtin("OverflowError", (errno.ERANGE,)) +_extend_builtin("OSError", [code for code in errno.errorcode if code not in classes]) + + +class ArgumentError(FFmpegError, ValueError): + def __str__(self): + msg = "" + if self.strerror is not None: + msg = f"{msg}{self.strerror}" + if self.filename: + msg = f"{msg}: {self.filename!r}" + if self.errno is not None: + msg = f"{msg} returned {self.errno}" + if self.log: + msg = ( + f"{msg}; last error log: [{self.log[1].strip()}] {self.log[2].strip()}" + ) + + return msg + + +class UndefinedError(FFmpegError): + """Fallback exception type in case FFmpeg returns an error we don't know about.""" + + pass + + +classes[errno.EINVAL] = ArgumentError +globals()["ArgumentError"] = ArgumentError +__all__.append("ArgumentError") + + +# Classes for the FFmpeg errors. +for enum_name, code, name, base in _ffmpeg_specs: + name = name or enum_name.title().replace("_", "") + "Error" + + if base is None: + bases = (FFmpegError,) + elif issubclass(base, FFmpegError): + bases = (base,) + else: + bases = (FFmpegError, base) + + cls = type(name, bases, {"__module__": __name__}) + + # Register in builder. + classes[code] = cls + + # Register in module. + globals()[name] = cls + __all__.append(name) + +del _ffmpeg_specs + + +# Storage for stashing. +_local = cython.declare(object, local()) +_err_count = cython.declare(cython.int, 0) +_last_log_count = cython.declare(cython.int, 0) + + +@cython.cfunc +def stash_exception(exc_info=None) -> cython.int: + global _err_count + + existing = getattr(_local, "exc_info", None) + if existing is not None: + fprintf(stderr, "PyAV library exception being dropped:\n") + traceback.print_exception(*existing) + _err_count -= 1 # Balance out the +=1 that is coming. + + exc_info = exc_info or sys.exc_info() + _local.exc_info = exc_info + if exc_info: + _err_count += 1 + + return -c_PYAV_STASHED_ERROR + + +@cython.ccall +@cython.exceptval(-1, check=False) +def err_check(res: cython.int, filename=None) -> cython.int: + """Raise appropriate exceptions from library return code.""" + + global _err_count + global _last_log_count + + # Check for stashed exceptions. + if _err_count: + exc_info = getattr(_local, "exc_info", None) + if exc_info is not None: + _err_count -= 1 + _local.exc_info = None + raise exc_info[1].with_traceback(exc_info[2]) + + if res >= 0: + return res + + # Grab details from the last log. + log_count, last_log = get_last_error() + if log_count > _last_log_count: + _last_log_count = log_count + log = last_log + else: + log = None + + code: cython.int = -res + error_buffer: cython.p_char = cython.cast( + cython.p_char, malloc(lib.AV_ERROR_MAX_STRING_SIZE * cython.sizeof(char)) + ) + if error_buffer == cython.NULL: + raise MemoryError() + + try: + if code == c_PYAV_STASHED_ERROR: + message = PYAV_STASHED_ERROR_message + else: + lib.av_strerror(res, error_buffer, lib.AV_ERROR_MAX_STRING_SIZE) + # Fallback to OS error string if no message + message = error_buffer or os.strerror(code) + + cls = classes.get(code, UndefinedError) + raise cls(code, message, filename, log) + finally: + free(error_buffer) diff --git a/av/error.pyi b/av/error.pyi new file mode 100644 index 000000000..1191d962f --- /dev/null +++ b/av/error.pyi @@ -0,0 +1,72 @@ +import builtins + +classes: dict[int, Exception] + +def code_to_tag(code: int) -> bytes: ... +def tag_to_code(tag: bytes) -> int: ... +def err_check(res: int, filename: str | None = None) -> int: ... + +class FFmpegError(Exception): + errno: int | None + strerror: str | None + filename: str + log: tuple[int, tuple[int, str, str] | None] + + def __init__( + self, + code: int, + message: str, + filename: str | None = None, + log: tuple[int, tuple[int, str, str] | None] | None = None, + ) -> None: ... + +class LookupError(FFmpegError): ... +class HTTPError(FFmpegError): ... +class HTTPClientError(FFmpegError): ... +class UndefinedError(FFmpegError): ... +class InvalidDataError(FFmpegError, builtins.ValueError): ... +class BugError(FFmpegError, builtins.RuntimeError): ... +class BufferTooSmallError(FFmpegError, builtins.ValueError): ... +class BSFNotFoundError(LookupError): ... +class DecoderNotFoundError(LookupError): ... +class DemuxerNotFoundError(LookupError): ... +class EncoderNotFoundError(LookupError): ... +class ExitError(FFmpegError): ... +class ExternalError(FFmpegError): ... +class FilterNotFoundError(LookupError): ... +class MuxerNotFoundError(LookupError): ... +class OptionNotFoundError(LookupError): ... +class PatchWelcomeError(FFmpegError): ... +class ProtocolNotFoundError(LookupError): ... +class UnknownError(FFmpegError): ... +class ExperimentalError(FFmpegError): ... +class InputChangedError(FFmpegError): ... +class OutputChangedError(FFmpegError): ... +class HTTPBadRequestError(HTTPClientError): ... +class HTTPUnauthorizedError(HTTPClientError): ... +class HTTPForbiddenError(HTTPClientError): ... +class HTTPNotFoundError(HTTPClientError): ... +class HTTPTooManyRequestsError(HTTPClientError): ... +class HTTPOtherClientError(HTTPClientError): ... +class HTTPServerError(HTTPError): ... +class PyAVCallbackError(FFmpegError, builtins.RuntimeError): ... +class BrokenPipeError(FFmpegError, builtins.BrokenPipeError): ... +class ChildProcessError(FFmpegError, builtins.ChildProcessError): ... +class ConnectionAbortedError(FFmpegError, builtins.ConnectionAbortedError): ... +class ConnectionRefusedError(FFmpegError, builtins.ConnectionRefusedError): ... +class ConnectionResetError(FFmpegError, builtins.ConnectionResetError): ... +class BlockingIOError(FFmpegError, builtins.BlockingIOError): ... +class EOFError(FFmpegError, builtins.EOFError): ... +class FileExistsError(FFmpegError, builtins.FileExistsError): ... +class FileNotFoundError(FFmpegError, builtins.FileNotFoundError): ... +class InterruptedError(FFmpegError, builtins.InterruptedError): ... +class IsADirectoryError(FFmpegError, builtins.IsADirectoryError): ... +class MemoryError(FFmpegError, builtins.MemoryError): ... +class NotADirectoryError(FFmpegError, builtins.NotADirectoryError): ... +class NotImplementedError(FFmpegError, builtins.NotImplementedError): ... +class OverflowError(FFmpegError, builtins.OverflowError): ... +class OSError(FFmpegError, builtins.OSError): ... +class PermissionError(FFmpegError, builtins.PermissionError): ... +class ProcessLookupError(FFmpegError, builtins.ProcessLookupError): ... +class TimeoutError(FFmpegError, builtins.TimeoutError): ... +class ArgumentError(FFmpegError): ... diff --git a/av/error.pyx b/av/error.pyx deleted file mode 100644 index cde4fec7f..000000000 --- a/av/error.pyx +++ /dev/null @@ -1,366 +0,0 @@ -cimport libav as lib - -from av.logging cimport get_last_error - -from threading import local -import errno -import os -import sys -import traceback - -from av.enum import define_enum - - -# Will get extended with all of the exceptions. -__all__ = [ - 'ErrorType', 'FFmpegError', 'LookupError', 'HTTPError', 'HTTPClientError', - 'UndefinedError', -] - - -cpdef code_to_tag(int code): - """Convert an integer error code into 4-byte tag. - - >>> code_to_tag(1953719668) - b'test' - - """ - return bytes(( - code & 0xff, - (code >> 8) & 0xff, - (code >> 16) & 0xff, - (code >> 24) & 0xff, - )) - -cpdef tag_to_code(bytes tag): - """Convert a 4-byte error tag into an integer code. - - >>> tag_to_code(b'test') - 1953719668 - - """ - if len(tag) != 4: - raise ValueError("Error tags are 4 bytes.") - return ( - (tag[0]) + - (tag[1] << 8) + - (tag[2] << 16) + - (tag[3] << 24) - ) - - -class FFmpegError(Exception): - - """Exception class for errors from within FFmpeg. - - .. attribute:: errno - - FFmpeg's integer error code. - - .. attribute:: strerror - - FFmpeg's error message. - - .. attribute:: filename - - The filename that was being operated on (if available). - - .. attribute:: type - - The :class:`av.error.ErrorType` enum value for the error type. - - .. attribute:: log - - The tuple from :func:`av.logging.get_last_log`, or ``None``. - - """ - - def __init__(self, code, message, filename=None, log=None): - args = [code, message] - if filename or log: - args.append(filename) - if log: - args.append(log) - super(FFmpegError, self).__init__(*args) - self.args = tuple(args) # FileNotFoundError/etc. only pulls 2 args. - self.type = ErrorType.get(code, create=True) - - @property - def errno(self): - return self.args[0] - - @property - def strerror(self): - return self.args[1] - - @property - def filename(self): - try: - return self.args[2] - except IndexError: - pass - - @property - def log(self): - try: - return self.args[3] - except IndexError: - pass - - def __str__(self): - - msg = f'[Errno {self.errno}] {self.strerror}' - - if self.filename: - msg = f'{msg}: {self.filename!r}' - - if self.log: - msg = f'{msg}; last error log: [{self.log[1].strip()}] {self.log[2].strip()}' - - return msg - - -# Our custom error, used in callbacks. -cdef int c_PYAV_STASHED_ERROR = tag_to_code(b'PyAV') -cdef str PYAV_STASHED_ERROR_message = 'Error in PyAV callback' - - -# Bases for the FFmpeg-based exceptions. -class LookupError(FFmpegError, LookupError): - pass - - -class HTTPError(FFmpegError): - pass - - -class HTTPClientError(FFmpegError): - pass - - -# Tuples of (enum_name, enum_value, exc_name, exc_base). -_ffmpeg_specs = ( - ('BSF_NOT_FOUND', -lib.AVERROR_BSF_NOT_FOUND, 'BSFNotFoundError', LookupError), - ('BUG', -lib.AVERROR_BUG, None, RuntimeError), - ('BUFFER_TOO_SMALL', -lib.AVERROR_BUFFER_TOO_SMALL, None, ValueError), - ('DECODER_NOT_FOUND', -lib.AVERROR_DECODER_NOT_FOUND, None, LookupError), - ('DEMUXER_NOT_FOUND', -lib.AVERROR_DEMUXER_NOT_FOUND, None, LookupError), - ('ENCODER_NOT_FOUND', -lib.AVERROR_ENCODER_NOT_FOUND, None, LookupError), - ('EOF', -lib.AVERROR_EOF, 'EOFError', EOFError), - ('EXIT', -lib.AVERROR_EXIT, None, None), - ('EXTERNAL', -lib.AVERROR_EXTERNAL, None, None), - ('FILTER_NOT_FOUND', -lib.AVERROR_FILTER_NOT_FOUND, None, LookupError), - ('INVALIDDATA', -lib.AVERROR_INVALIDDATA, 'InvalidDataError', ValueError), - ('MUXER_NOT_FOUND', -lib.AVERROR_MUXER_NOT_FOUND, None, LookupError), - ('OPTION_NOT_FOUND', -lib.AVERROR_OPTION_NOT_FOUND, None, LookupError), - ('PATCHWELCOME', -lib.AVERROR_PATCHWELCOME, 'PatchWelcomeError', None), - ('PROTOCOL_NOT_FOUND', -lib.AVERROR_PROTOCOL_NOT_FOUND, None, LookupError), - ('UNKNOWN', -lib.AVERROR_UNKNOWN, None, None), - ('EXPERIMENTAL', -lib.AVERROR_EXPERIMENTAL, None, None), - ('INPUT_CHANGED', -lib.AVERROR_INPUT_CHANGED, None, None), - ('OUTPUT_CHANGED', -lib.AVERROR_OUTPUT_CHANGED, None, None), - ('HTTP_BAD_REQUEST', -lib.AVERROR_HTTP_BAD_REQUEST, 'HTTPBadRequestError', HTTPClientError), - ('HTTP_UNAUTHORIZED', -lib.AVERROR_HTTP_UNAUTHORIZED, 'HTTPUnauthorizedError', HTTPClientError), - ('HTTP_FORBIDDEN', -lib.AVERROR_HTTP_FORBIDDEN, 'HTTPForbiddenError', HTTPClientError), - ('HTTP_NOT_FOUND', -lib.AVERROR_HTTP_NOT_FOUND, 'HTTPNotFoundError', HTTPClientError), - ('HTTP_OTHER_4XX', -lib.AVERROR_HTTP_OTHER_4XX, 'HTTPOtherClientError', HTTPClientError), - ('HTTP_SERVER_ERROR', -lib.AVERROR_HTTP_SERVER_ERROR, 'HTTPServerError', HTTPError), - ('PYAV_CALLBACK', c_PYAV_STASHED_ERROR, 'PyAVCallbackError', RuntimeError), -) - - -# The actual enum. -ErrorType = define_enum("ErrorType", __name__, [x[:2] for x in _ffmpeg_specs]) - -# It has to be monkey-patched. -ErrorType.__doc__ = """An enumeration of FFmpeg's error types. - -.. attribute:: tag - - The FFmpeg byte tag for the error. - -.. attribute:: strerror - - The error message that would be returned. - -""" -ErrorType.tag = property(lambda self: code_to_tag(self.value)) - - -for enum in ErrorType: - - # Mimick the errno module. - globals()[enum.name] = enum - if enum.value == c_PYAV_STASHED_ERROR: - enum.strerror = PYAV_STASHED_ERROR_message - else: - enum.strerror = lib.av_err2str(-enum.value) - - -# Mimick the builtin exception types. -# See https://www.python.org/dev/peps/pep-3151/#new-exception-classes -# Use the named ones we have, otherwise default to OSError for anything in errno. -r''' - -See this command for the count of POSIX codes used: - - egrep -IR 'AVERROR\(E[A-Z]+\)' vendor/ffmpeg-4.2 |\ - sed -E 's/.*AVERROR\((E[A-Z]+)\).*/\1/' | \ - sort | uniq -c - -The biggest ones that don't map to PEP 3151 builtins: - - 2106 EINVAL -> ValueError - 649 EIO -> IOError (if it is distinct from OSError) - 4080 ENOMEM -> MemoryError - 340 ENOSYS -> NotImplementedError - 35 ERANGE -> OverflowError - -''' - -classes = {} - - -def _extend_builtin(name, codes): - base = getattr(__builtins__, name, OSError) - cls = type(name, (FFmpegError, base), dict(__module__=__name__)) - - # Register in builder. - for code in codes: - classes[code] = cls - - # Register in module. - globals()[name] = cls - __all__.append(name) - - return cls - - -# PEP 3151 builtins. -_extend_builtin('PermissionError', (errno.EACCES, errno.EPERM)) -_extend_builtin('BlockingIOError', (errno.EAGAIN, errno.EALREADY, errno.EINPROGRESS, errno.EWOULDBLOCK)) -_extend_builtin('ChildProcessError', (errno.ECHILD, )) -_extend_builtin('ConnectionAbortedError', (errno.ECONNABORTED, )) -_extend_builtin('ConnectionRefusedError', (errno.ECONNREFUSED, )) -_extend_builtin('ConnectionResetError', (errno.ECONNRESET, )) -_extend_builtin('FileExistsError', (errno.EEXIST, )) -_extend_builtin('InterruptedError', (errno.EINTR, )) -_extend_builtin('IsADirectoryError', (errno.EISDIR, )) -_extend_builtin('FileNotFoundError', (errno.ENOENT, )) -_extend_builtin('NotADirectoryError', (errno.ENOTDIR, )) -_extend_builtin('BrokenPipeError', (errno.EPIPE, errno.ESHUTDOWN)) -_extend_builtin('ProcessLookupError', (errno.ESRCH, )) -_extend_builtin('TimeoutError', (errno.ETIMEDOUT, )) - -# Other obvious ones. -_extend_builtin('ValueError', (errno.EINVAL, )) -_extend_builtin('MemoryError', (errno.ENOMEM, )) -_extend_builtin('NotImplementedError', (errno.ENOSYS, )) -_extend_builtin('OverflowError', (errno.ERANGE, )) -if IOError is not OSError: - _extend_builtin('IOError', (errno.EIO, )) - -# The rest of them (for now) -_extend_builtin('OSError', [code for code in errno.errorcode if code not in classes]) - -# Classes for the FFmpeg errors. -for enum_name, code, name, base in _ffmpeg_specs: - name = name or enum_name.title().replace('_', '') + 'Error' - - if base is None: - bases = (FFmpegError, ) - elif issubclass(base, FFmpegError): - bases = (base, ) - else: - bases = (FFmpegError, base) - - cls = type(name, bases, dict(__module__=__name__)) - - # Register in builder. - classes[code] = cls - - # Register in module. - globals()[name] = cls - __all__.append(name) - - -# Storage for stashing. -cdef object _local = local() -cdef int _err_count = 0 - -cdef int stash_exception(exc_info=None): - - global _err_count - - existing = getattr(_local, 'exc_info', None) - if existing is not None: - print >> sys.stderr, 'PyAV library exception being dropped:' - traceback.print_exception(*existing) - _err_count -= 1 # Balance out the +=1 that is coming. - - exc_info = exc_info or sys.exc_info() - _local.exc_info = exc_info - if exc_info: - _err_count += 1 - - return -c_PYAV_STASHED_ERROR - - -cdef int _last_log_count = 0 - -cpdef int err_check(int res, filename=None) except -1: - """Raise appropriate exceptions from library return code.""" - - global _err_count - global _last_log_count - - # Check for stashed exceptions. - if _err_count: - exc_info = getattr(_local, 'exc_info', None) - if exc_info is not None: - _err_count -= 1 - _local.exc_info = None - raise exc_info[0], exc_info[1], exc_info[2] - - if res >= 0: - return res - - # Grab details from the last log. - log_count, last_log = get_last_error() - if log_count > _last_log_count: - _last_log_count = log_count - log = last_log - else: - log = None - - raise make_error(res, filename, log) - - -class UndefinedError(FFmpegError): - """Fallback exception type in case FFmpeg returns an error we don't know about.""" - pass - - -cpdef make_error(int res, filename=None, log=None): - - cdef int code = -res - cdef bytes py_buffer - cdef char *c_buffer - - if code == c_PYAV_STASHED_ERROR: - message = PYAV_STASHED_ERROR_message - - else: - - # Jump through some hoops due to Python 2 in same codebase. - py_buffer = b"\0" * lib.AV_ERROR_MAX_STRING_SIZE - c_buffer = py_buffer - lib.av_strerror(res, c_buffer, lib.AV_ERROR_MAX_STRING_SIZE) - py_buffer = c_buffer - message = py_buffer.decode('latin1') - - # Default to the OS if we have no message; this should not get called. - message = message or os.strerror(code) - - cls = classes.get(code, UndefinedError) - return cls(code, message, filename, log) diff --git a/av/filter/__init__.pxd b/av/filter/__init__.pxd new file mode 100644 index 000000000..e69de29bb diff --git a/av/filter/__init__.py b/av/filter/__init__.py index 207d6e15e..cffc49e7d 100644 --- a/av/filter/__init__.py +++ b/av/filter/__init__.py @@ -1,2 +1,3 @@ -from .filter import Filter, FilterFlags, filter_descriptor, filters_available -from .graph import Graph +from .filter import Filter, filters_available +from .graph import Graph as Graph +from .loudnorm import stats as stats diff --git a/av/filter/__init__.pyi b/av/filter/__init__.pyi new file mode 100644 index 000000000..5be1326c9 --- /dev/null +++ b/av/filter/__init__.pyi @@ -0,0 +1,4 @@ +from .context import * +from .filter import * +from .graph import * +from .loudnorm import * diff --git a/av/filter/context.pxd b/av/filter/context.pxd index 3c69185b2..d67e7e1e7 100644 --- a/av/filter/context.pxd +++ b/av/filter/context.pxd @@ -4,16 +4,14 @@ from av.filter.filter cimport Filter from av.filter.graph cimport Graph -cdef class FilterContext(object): - +cdef class FilterContext: cdef lib.AVFilterContext *ptr - cdef readonly Graph graph + cdef readonly object _graph cdef readonly Filter filter - cdef object _inputs cdef object _outputs - cdef bint inited + cdef unsigned char _kind cdef FilterContext wrap_filter_context(Graph graph, Filter filter, lib.AVFilterContext *ptr) diff --git a/av/filter/context.py b/av/filter/context.py new file mode 100644 index 000000000..1dfd6e7a4 --- /dev/null +++ b/av/filter/context.py @@ -0,0 +1,206 @@ +import weakref + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.audio.frame import alloc_audio_frame +from cython.cimports.av.dictionary import Dictionary +from cython.cimports.av.error import err_check +from cython.cimports.av.filter.link import alloc_filter_pads +from cython.cimports.av.frame import Frame +from cython.cimports.av.utils import avrational_to_fraction +from cython.cimports.av.video.frame import alloc_video_frame + +_cinit_sentinel = cython.declare(object, object()) + +_KIND_OTHER = cython.declare(cython.uchar, 0) +_KIND_SOURCE = cython.declare(cython.uchar, 1) # buffer / abuffer +_KIND_VIDEO_SINK = cython.declare(cython.uchar, 2) # buffersink +_KIND_AUDIO_SINK = cython.declare(cython.uchar, 3) # abuffersink + + +@cython.cfunc +def wrap_filter_context( + graph: Graph, filter: Filter, ptr: cython.pointer[lib.AVFilterContext] +) -> FilterContext: + self: FilterContext = FilterContext(_cinit_sentinel) + self._graph = weakref.ref(graph) + self.filter = filter + self.ptr = ptr + + name: str = filter.name + if name == "buffer" or name == "abuffer": + self._kind = _KIND_SOURCE + elif name == "buffersink": + self._kind = _KIND_VIDEO_SINK + elif name == "abuffersink": + self._kind = _KIND_AUDIO_SINK + else: + self._kind = _KIND_OTHER + return self + + +@cython.final +@cython.cclass +class FilterContext: + def __cinit__(self, sentinel): + if sentinel is not _cinit_sentinel: + raise RuntimeError("cannot construct FilterContext") + + def __repr__(self): + if self.ptr != cython.NULL: + name = repr(self.ptr.name) if self.ptr.name != cython.NULL else "" + else: + name = "None" + + parent = ( + self.filter.ptr.name + if self.filter and self.filter.ptr != cython.NULL + else None + ) + return f"" + + @property + def name(self): + if self.ptr.name != cython.NULL: + return self.ptr.name + + @property + def inputs(self): + if self._inputs is None: + self._inputs = alloc_filter_pads( + self.filter, self.ptr.input_pads, True, self + ) + return self._inputs + + @property + def outputs(self): + if self._outputs is None: + self._outputs = alloc_filter_pads( + self.filter, self.ptr.output_pads, False, self + ) + return self._outputs + + def init(self, args=None, **kwargs): + if self.inited: + raise ValueError("already inited") + if args and kwargs: + raise ValueError("cannot init from args and kwargs") + + dict_: Dictionary = None + c_args: cython.p_char = cython.NULL + if args or not kwargs: + if args: + c_args = args + err_check(lib.avfilter_init_str(self.ptr, c_args)) + else: + dict_ = Dictionary(kwargs) + err_check(lib.avfilter_init_dict(self.ptr, cython.address(dict_.ptr))) + + self.inited = True + if dict_: + raise ValueError(f"unused config: {', '.join(sorted(dict_))}") + + def link_to( + self, + input_: FilterContext, + output_idx: cython.int = 0, + input_idx: cython.int = 0, + ): + err_check(lib.avfilter_link(self.ptr, output_idx, input_.ptr, input_idx)) + + @property + def graph(self): + if graph := self._graph(): + return graph + else: + raise RuntimeError("graph is unallocated") + + def push(self, frame: Frame | None): + res: cython.int + + if frame is None: + with cython.nogil: + res = lib.av_buffersrc_write_frame(self.ptr, cython.NULL) + err_check(res) + return + elif self._kind == _KIND_SOURCE: + with cython.nogil: + res = lib.av_buffersrc_write_frame(self.ptr, frame.ptr) + err_check(res) + return + + # Delegate to the input. + if len(self.inputs) != 1: + raise ValueError( + f"cannot delegate push without single input; found {len(self.inputs)}" + ) + if not self.inputs[0].link: + raise ValueError("cannot delegate push without linked input") + self.inputs[0].linked.context.push(frame) + + def pull(self): + frame: Frame + res: cython.int + if self._kind == _KIND_VIDEO_SINK: + frame = alloc_video_frame() + elif self._kind == _KIND_AUDIO_SINK: + frame = alloc_audio_frame() + else: + # Delegate to the output. + if len(self.outputs) != 1: + raise ValueError( + f"cannot delegate pull without single output; found {len(self.outputs)}" + ) + if not self.outputs[0].link: + raise ValueError("cannot delegate pull without linked output") + return self.outputs[0].linked.context.pull() + + self.graph.configure() + + with cython.nogil: + res = lib.av_buffersink_get_frame(self.ptr, frame.ptr) + err_check(res) + + frame._init_user_attributes() + frame.time_base = avrational_to_fraction( + cython.address(self.ptr.inputs[0].time_base) + ) + return frame + + def process_command( + self, cmd, arg=None, res_len: cython.int = 1024, flags: cython.int = 0 + ): + if not cmd: + raise ValueError("Invalid cmd") + + c_arg: cython.p_char = cython.NULL + c_cmd: cython.p_char = cmd + if arg is not None: + c_arg = arg + + c_res: cython.p_char = cython.NULL + ret: cython.int + res_buf: bytearray = None + view: cython.uchar[:] + b: bytes + nul: cython.int + + if res_len > 0: + res_buf = bytearray(res_len) + view = res_buf + c_res = cython.cast(cython.p_char, cython.address(view[0])) + + with cython.nogil: + ret = lib.avfilter_process_command( + self.ptr, c_cmd, c_arg, c_res, res_len, flags + ) + err_check(ret) + + if res_buf is not None: + b = bytes(res_buf) + nul = b.find(b"\x00") + if nul >= 0: + b = b[:nul] + if b: + return b.decode("utf-8", "strict") + return None diff --git a/av/filter/context.pyi b/av/filter/context.pyi new file mode 100644 index 000000000..2350019f3 --- /dev/null +++ b/av/filter/context.pyi @@ -0,0 +1,17 @@ +from av.filter import Graph +from av.frame import Frame + +class FilterContext: + name: str | None + + def init(self, args: str | None = None, **kwargs: str | None) -> None: ... + def link_to( + self, input_: FilterContext, output_idx: int = 0, input_idx: int = 0 + ) -> None: ... + @property + def graph(self) -> Graph: ... + def push(self, frame: Frame | None) -> None: ... + def pull(self) -> Frame: ... + def process_command( + self, cmd: str, arg: str | None = None, res_len: int = 1024, flags: int = 0 + ) -> str | None: ... diff --git a/av/filter/context.pyx b/av/filter/context.pyx deleted file mode 100644 index bd027aa34..000000000 --- a/av/filter/context.pyx +++ /dev/null @@ -1,109 +0,0 @@ -from libc.string cimport memcpy - -from av.audio.frame cimport AudioFrame, alloc_audio_frame -from av.dictionary cimport _Dictionary -from av.dictionary import Dictionary -from av.error cimport err_check -from av.filter.pad cimport alloc_filter_pads -from av.frame cimport Frame -from av.video.frame cimport VideoFrame, alloc_video_frame - - -cdef object _cinit_sentinel = object() - - -cdef FilterContext wrap_filter_context(Graph graph, Filter filter, lib.AVFilterContext *ptr): - cdef FilterContext self = FilterContext(_cinit_sentinel) - self.graph = graph - self.filter = filter - self.ptr = ptr - return self - - -cdef class FilterContext(object): - - def __cinit__(self, sentinel): - if sentinel is not _cinit_sentinel: - raise RuntimeError('cannot construct FilterContext') - - def __repr__(self): - return '' % ( - (repr(self.ptr.name) if self.ptr.name != NULL else '') if self.ptr != NULL else 'None', - self.filter.ptr.name if self.filter and self.filter.ptr != NULL else None, - id(self), - ) - - property name: - def __get__(self): - if self.ptr.name != NULL: - return self.ptr.name - - property inputs: - def __get__(self): - if self._inputs is None: - self._inputs = alloc_filter_pads(self.filter, self.ptr.input_pads, True, self) - return self._inputs - - property outputs: - def __get__(self): - if self._outputs is None: - self._outputs = alloc_filter_pads(self.filter, self.ptr.output_pads, False, self) - return self._outputs - - def init(self, args=None, **kwargs): - - if self.inited: - raise ValueError('already inited') - if args and kwargs: - raise ValueError('cannot init from args and kwargs') - - cdef _Dictionary dict_ = None - cdef char *c_args = NULL - if args or not kwargs: - if args: - c_args = args - err_check(lib.avfilter_init_str(self.ptr, c_args)) - else: - dict_ = Dictionary(kwargs) - err_check(lib.avfilter_init_dict(self.ptr, &dict_.ptr)) - - self.inited = True - if dict_: - raise ValueError('unused config: %s' % ', '.join(sorted(dict_))) - - def link_to(self, FilterContext input_, int output_idx=0, int input_idx=0): - err_check(lib.avfilter_link(self.ptr, output_idx, input_.ptr, input_idx)) - - def push(self, Frame frame): - - if self.filter.name in ('abuffer', 'buffer'): - err_check(lib.av_buffersrc_write_frame(self.ptr, frame.ptr)) - return - - # Delegate to the input. - if len(self.inputs) != 1: - raise ValueError('cannot delegate push without single input; found %d' % len(self.inputs)) - if not self.inputs[0].link: - raise ValueError('cannot delegate push without linked input') - self.inputs[0].linked.context.push(frame) - - def pull(self): - - cdef Frame frame - if self.filter.name == 'buffersink': - frame = alloc_video_frame() - elif self.filter.name == 'abuffersink': - frame = alloc_audio_frame() - else: - # Delegate to the output. - if len(self.outputs) != 1: - raise ValueError('cannot delegate pull without single output; found %d' % len(self.outputs)) - if not self.outputs[0].link: - raise ValueError('cannot delegate pull without linked output') - return self.outputs[0].linked.context.pull() - - self.graph.configure() - - err_check(lib.av_buffersink_get_frame(self.ptr, frame.ptr)) - frame._init_user_attributes() - return frame diff --git a/av/filter/filter.pxd b/av/filter/filter.pxd index e3d937e7c..d74065a19 100644 --- a/av/filter/filter.pxd +++ b/av/filter/filter.pxd @@ -1,15 +1,10 @@ cimport libav as lib -from av.descriptor cimport Descriptor - - -cdef class Filter(object): +cdef class Filter: cdef const lib.AVFilter *ptr - cdef object _inputs cdef object _outputs - cdef Descriptor _descriptor cdef Filter wrap_filter(const lib.AVFilter *ptr) diff --git a/av/filter/filter.py b/av/filter/filter.py new file mode 100644 index 000000000..acbbc111a --- /dev/null +++ b/av/filter/filter.py @@ -0,0 +1,67 @@ +import cython +from cython.cimports import libav as lib +from cython.cimports.av.filter.link import alloc_filter_pads + +_cinit_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_filter(ptr: cython.pointer[cython.const[lib.AVFilter]]) -> Filter: + filter_: Filter = Filter(_cinit_sentinel) + filter_.ptr = ptr + return filter_ + + +@cython.final +@cython.cclass +class Filter: + def __cinit__(self, name): + if name is _cinit_sentinel: + return + if not isinstance(name, str): + raise TypeError("takes a filter name as a string") + + self.ptr = lib.avfilter_get_by_name(name) + if not self.ptr: + raise ValueError(f"no filter {name}") + + @property + def name(self): + return self.ptr.name + + @property + def description(self): + return self.ptr.description + + @property + def flags(self): + return self.ptr.flags + + @property + def inputs(self): + if self._inputs is None: + self._inputs = alloc_filter_pads(self, self.ptr.inputs, True) + return self._inputs + + @property + def outputs(self): + if self._outputs is None: + self._outputs = alloc_filter_pads(self, self.ptr.outputs, False) + return self._outputs + + +@cython.cfunc +def get_filter_names() -> set: + names: set = set() + ptr: cython.pointer[cython.const[lib.AVFilter]] + opaque: cython.p_void = cython.NULL + while True: + ptr = lib.av_filter_iterate(cython.address(opaque)) + if ptr: + names.add(ptr.name) + else: + break + return names + + +filters_available = get_filter_names() diff --git a/av/filter/filter.pyi b/av/filter/filter.pyi new file mode 100644 index 000000000..090d4f1d0 --- /dev/null +++ b/av/filter/filter.pyi @@ -0,0 +1,8 @@ +class Filter: + name: str + description: str + flags: int + + def __init__(self, name: str) -> None: ... + +filters_available: set[str] diff --git a/av/filter/filter.pyx b/av/filter/filter.pyx deleted file mode 100644 index f5b5f1eee..000000000 --- a/av/filter/filter.pyx +++ /dev/null @@ -1,107 +0,0 @@ -cimport libav as lib - -from av.descriptor cimport wrap_avclass -from av.filter.pad cimport alloc_filter_pads - - -cdef object _cinit_sentinel = object() - - -cdef Filter wrap_filter(const lib.AVFilter *ptr): - cdef Filter filter_ = Filter(_cinit_sentinel) - filter_.ptr = ptr - return filter_ - - -cpdef enum FilterFlags: - DYNAMIC_INPUTS = lib.AVFILTER_FLAG_DYNAMIC_INPUTS - DYNAMIC_OUTPUTS = lib.AVFILTER_FLAG_DYNAMIC_OUTPUTS - SLICE_THREADS = lib.AVFILTER_FLAG_SLICE_THREADS - SUPPORT_TIMELINE_GENERIC = lib.AVFILTER_FLAG_SUPPORT_TIMELINE_GENERIC - SUPPORT_TIMELINE_INTERNAL = lib.AVFILTER_FLAG_SUPPORT_TIMELINE_INTERNAL - - -cdef class Filter(object): - - def __cinit__(self, name): - if name is _cinit_sentinel: - return - if not isinstance(name, str): - raise TypeError('takes a filter name as a string') - self.ptr = lib.avfilter_get_by_name(name) - if not self.ptr: - raise ValueError('no filter %s' % name) - - property descriptor: - def __get__(self): - if self._descriptor is None: - self._descriptor = wrap_avclass(self.ptr.priv_class) - return self._descriptor - - property options: - def __get__(self): - if self.descriptor is None: - return - return self.descriptor.options - - property name: - def __get__(self): - return self.ptr.name - - property description: - def __get__(self): - return self.ptr.description - - property flags: - def __get__(self): - return self.ptr.flags - - property dynamic_inputs: - def __get__(self): - return bool(self.ptr.flags & lib.AVFILTER_FLAG_DYNAMIC_INPUTS) - - property dynamic_outputs: - def __get__(self): - return bool(self.ptr.flags & lib.AVFILTER_FLAG_DYNAMIC_OUTPUTS) - - property timeline_support: - def __get__(self): - return bool(self.ptr.flags & lib.AVFILTER_FLAG_SUPPORT_TIMELINE_GENERIC) - - property slice_threads: - def __get__(self): - return bool(self.ptr.flags & lib.AVFILTER_FLAG_SLICE_THREADS) - - property command_support: - def __get__(self): - return self.ptr.process_command != NULL - - property inputs: - def __get__(self): - if self._inputs is None: - self._inputs = alloc_filter_pads(self, self.ptr.inputs, True) - return self._inputs - - property outputs: - def __get__(self): - if self._outputs is None: - self._outputs = alloc_filter_pads(self, self.ptr.outputs, False) - return self._outputs - - -cdef get_filter_names(): - names = set() - cdef const lib.AVFilter *ptr - cdef void *opaque = NULL - while True: - ptr = lib.av_filter_iterate(&opaque) - if ptr: - names.add(ptr.name) - else: - break - return names - -filters_available = get_filter_names() - - -filter_descriptor = wrap_avclass(lib.avfilter_get_class()) diff --git a/av/filter/graph.pxd b/av/filter/graph.pxd index e01536527..f67053016 100644 --- a/av/filter/graph.pxd +++ b/av/filter/graph.pxd @@ -3,7 +3,8 @@ cimport libav as lib from av.filter.context cimport FilterContext -cdef class Graph(object): +cdef class Graph: + cdef object __weakref__ cdef lib.AVFilterGraph *ptr @@ -12,10 +13,12 @@ cdef class Graph(object): cdef dict _name_counts cdef str _get_unique_name(self, str name) + cdef list[FilterContext] _get_context_by_type(self, str type) cdef _register_context(self, FilterContext) cdef _auto_register(self) cdef int _nb_filters_seen - cdef dict _context_by_ptr - cdef dict _context_by_name - cdef dict _context_by_type + cdef dict[size_t, FilterContext] _context_by_ptr + cdef dict[str, list[FilterContext]] _context_by_type + cdef list[FilterContext] _video_sources + cdef list[FilterContext] _audio_sources diff --git a/av/filter/graph.py b/av/filter/graph.py new file mode 100644 index 000000000..3e4898ab6 --- /dev/null +++ b/av/filter/graph.py @@ -0,0 +1,306 @@ +import warnings +from fractions import Fraction + +import cython +from cython.cimports.av.audio.format import AudioFormat +from cython.cimports.av.audio.frame import AudioFrame +from cython.cimports.av.audio.layout import AudioLayout +from cython.cimports.av.error import err_check +from cython.cimports.av.filter.context import FilterContext, wrap_filter_context +from cython.cimports.av.filter.filter import Filter, wrap_filter +from cython.cimports.av.video.format import VideoFormat +from cython.cimports.av.video.frame import VideoFrame + + +@cython.final +@cython.cclass +class Graph: + def __cinit__(self): + self.ptr = lib.avfilter_graph_alloc() + self.configured = False + self._name_counts = {} + self._nb_filters_seen = 0 + self._context_by_ptr = {} + self._context_by_type = {} + self._video_sources = [] + self._audio_sources = [] + + def __dealloc__(self): + if self.ptr: + # This frees the graph, filter contexts, links, etc.. + lib.avfilter_graph_free(cython.address(self.ptr)) + + @property + def threads(self): + """Maximum number of threads used by filters in this graph. + + Set to 0 for automatic thread count. Must be set before adding any + filters to the graph. + + Wraps :ffmpeg:`AVFilterGraph.nb_threads`. + """ + return self.ptr.nb_threads + + @threads.setter + def threads(self, value: cython.int): + if self.ptr.nb_filters: + raise RuntimeError("Cannot change threads after filters have been added.") + self.ptr.nb_threads = value + + @cython.cfunc + def _get_unique_name(self, name: str) -> str: + count = self._name_counts.get(name, 0) + self._name_counts[name] = count + 1 + if count: + return f"{name}_{count}" + else: + return name + + @cython.cfunc + @cython.inline + def _get_context_by_type(self, type_: str) -> list[FilterContext]: + return self._context_by_type.get(type_, []) + + @cython.ccall + def configure(self, auto_buffer: cython.bint = True, force: cython.bint = False): + if self.configured and not force: + return + + err_check(lib.avfilter_graph_config(self.ptr, cython.NULL)) + self.configured = True + + # We get auto-inserted stuff here. + self._auto_register() + + def link_nodes(self, *nodes): + """ + Links nodes together for simple filter graphs. + """ + for c, n in zip(nodes, nodes[1:]): + c.link_to(n) + return self + + def add(self, filter, args=None, **kwargs): + cy_filter: Filter + if isinstance(filter, str): + cy_filter = Filter(filter) + elif isinstance(filter, Filter): + cy_filter = filter + else: + raise TypeError("filter must be a string or Filter") + + name: str = self._get_unique_name(kwargs.pop("name", None) or cy_filter.name) + ptr: cython.pointer[lib.AVFilterContext] = lib.avfilter_graph_alloc_filter( + self.ptr, cy_filter.ptr, name + ) + if not ptr: + raise RuntimeError("Could not allocate AVFilterContext") + + # Manually construct this context (so we can return it). + ctx: FilterContext = wrap_filter_context(self, cy_filter, ptr) + ctx.init(args, **kwargs) + self._register_context(ctx) + + # There might have been automatic contexts added (e.g. resamplers, + # fifos, and scalers). It is more likely to see them after the graph + # is configured, but we want to be safe. + self._auto_register() + + return ctx + + @cython.cfunc + def _register_context(self, ctx: FilterContext): + name: str = ctx.filter.ptr.name + self._context_by_ptr[cython.cast(cython.size_t, ctx.ptr)] = ctx + self._context_by_type.setdefault(name, []).append(ctx) + if name == "buffer": + self._video_sources.append(ctx) + elif name == "abuffer": + self._audio_sources.append(ctx) + + @cython.cfunc + def _auto_register(self): + i: cython.int + c_ctx: cython.pointer[lib.AVFilterContext] + filter_: Filter + py_ctx: FilterContext + # We assume that filters are never removed from the graph. At this + # point we don't expose that in the API, so we should be okay... + for i in range(self._nb_filters_seen, self.ptr.nb_filters): + c_ctx = self.ptr.filters[i] + if cython.cast(cython.size_t, c_ctx) in self._context_by_ptr: + continue + filter_ = wrap_filter(c_ctx.filter) + py_ctx = wrap_filter_context(self, filter_, c_ctx) + self._register_context(py_ctx) + self._nb_filters_seen = self.ptr.nb_filters + + def add_buffer( + self, + template=None, + width=None, + height=None, + format=None, + name=None, + time_base=None, + ): + if template is not None: + if width is None: + width = template.width + if height is None: + height = template.height + if format is None: + format = template.format + if time_base is None: + time_base = template.time_base + + if width is None: + raise ValueError("missing width") + if height is None: + raise ValueError("missing height") + if format is None: + raise ValueError("missing format") + if time_base is None: + warnings.warn( + "missing time_base. Guessing 1/1000 time base. " + "This is deprecated and may be removed in future releases.", + DeprecationWarning, + ) + time_base = Fraction(1, 1000) + + return self.add( + "buffer", + name=name, + video_size=f"{width}x{height}", + pix_fmt=str(int(VideoFormat(format))), + time_base=str(time_base), + pixel_aspect="1/1", + ) + + def add_abuffer( + self, + template=None, + sample_rate=None, + format=None, + layout=None, + channels=None, + name=None, + time_base=None, + ): + """ + Convenience method for adding `abuffer `_. + """ + + if template is not None: + if sample_rate is None: + sample_rate = template.sample_rate + if format is None: + format = template.format + if layout is None: + layout = template.layout.name + if channels is None: + channels = template.channels + if time_base is None: + time_base = template.time_base + + if sample_rate is None: + raise ValueError("missing sample_rate") + if format is None: + raise ValueError("missing format") + if layout is None and channels is None: + raise ValueError("missing layout or channels") + if time_base is None: + time_base = Fraction(1, sample_rate) + + kwargs = { + "sample_rate": f"{sample_rate}", + "sample_fmt": AudioFormat(format).name, + "time_base": f"{time_base}", + } + if layout: + kwargs["channel_layout"] = AudioLayout(layout).name + if channels: + kwargs["channels"] = f"{channels}" + + return self.add("abuffer", name=name, **kwargs) + + def set_audio_frame_size(self, frame_size): + """ + Set the audio frame size for the graphs `abuffersink`. + See `av_buffersink_set_frame_size `_. + """ + if not self.configured: + raise ValueError("graph not configured") + sinks = self._get_context_by_type("abuffersink") + if not sinks: + raise ValueError("missing abuffersink filter") + for sink in sinks: + lib.av_buffersink_set_frame_size( + cython.cast(FilterContext, sink).ptr, frame_size + ) + + def push(self, frame, at: cython.int = -1): + """Push a frame into the graph's buffer source(s). + + :param frame: An :class:`.AudioFrame` or :class:`.VideoFrame` to push into + the matching buffer sources, or ``None`` to signal end of stream to + every buffer source. + :param int at: Index of a single buffer source to push to, for graphs with + multiple inputs (e.g. ``overlay``). The default of ``-1`` pushes to + every buffer source matching the frame's type. + """ + if frame is None: + contexts = self._video_sources + self._audio_sources + elif isinstance(frame, VideoFrame): + contexts = self._video_sources + elif isinstance(frame, AudioFrame): + contexts = self._audio_sources + else: + raise ValueError( + f"can only AudioFrame, VideoFrame or None; got {type(frame)}" + ) + + if at >= 0: + if at >= len(contexts): + raise IndexError( + f"buffer source index {at} out of range; found {len(contexts)}" + ) + contexts[at].push(frame) + return + + for ctx in contexts: + ctx.push(frame) + + def vpush(self, frame: VideoFrame | None, at: cython.int = -1): + """Like :meth:`push`, but only for :class:`.VideoFrame`.""" + contexts = self._video_sources + if at >= 0: + if at >= len(contexts): + raise IndexError( + f"buffer source index {at} out of range; found {len(contexts)}" + ) + contexts[at].push(frame) + return + + for ctx in contexts: + ctx.push(frame) + + # TODO: Test complex filter graphs, add `at: int = 0` arg to pull() and vpull(). + def pull(self): + vsinks = self._get_context_by_type("buffersink") + asinks = self._get_context_by_type("abuffersink") + + nsinks = len(vsinks) + len(asinks) + if nsinks != 1: + raise ValueError(f"can only auto-pull with single sink; found {nsinks}") + + return (vsinks or asinks)[0].pull() + + def vpull(self): + """Like `pull`, but only for VideoFrames.""" + vsinks = self._get_context_by_type("buffersink") + nsinks = len(vsinks) + if nsinks != 1: + raise ValueError(f"can only auto-pull with single sink; found {nsinks}") + + return vsinks[0].pull() diff --git a/av/filter/graph.pyi b/av/filter/graph.pyi new file mode 100644 index 000000000..8f3231fc2 --- /dev/null +++ b/av/filter/graph.pyi @@ -0,0 +1,48 @@ +from fractions import Fraction +from typing import Any + +from av.audio.format import AudioFormat +from av.audio.frame import AudioFrame +from av.audio.layout import AudioLayout +from av.audio.stream import AudioStream +from av.video.format import VideoFormat +from av.video.frame import VideoFrame +from av.video.stream import VideoStream + +from .context import FilterContext +from .filter import Filter + +class Graph: + configured: bool + threads: int + + def __init__(self) -> None: ... + def configure(self, auto_buffer: bool = True, force: bool = False) -> None: ... + def link_nodes(self, *nodes: FilterContext) -> Graph: ... + def add( + self, filter: str | Filter, args: Any = None, **kwargs: str + ) -> FilterContext: ... + def add_buffer( + self, + template: VideoStream | None = None, + width: int | None = None, + height: int | None = None, + format: VideoFormat | None = None, + name: str | None = None, + time_base: Fraction | None = None, + ) -> FilterContext: ... + def add_abuffer( + self, + template: AudioStream | None = None, + sample_rate: int | None = None, + format: AudioFormat | str | None = None, + layout: AudioLayout | str | None = None, + channels: int | None = None, + name: str | None = None, + time_base: Fraction | None = None, + ) -> FilterContext: ... + def set_audio_frame_size(self, frame_size: int) -> None: ... + def push(self, frame: None | AudioFrame | VideoFrame, at: int = -1) -> None: ... + def pull(self) -> VideoFrame | AudioFrame: ... + def vpush(self, frame: VideoFrame | None, at: int = -1) -> None: ... + def vpull(self) -> VideoFrame: ... diff --git a/av/filter/graph.pyx b/av/filter/graph.pyx deleted file mode 100644 index 110da0e52..000000000 --- a/av/filter/graph.pyx +++ /dev/null @@ -1,212 +0,0 @@ -from fractions import Fraction - -from av.audio.format cimport AudioFormat -from av.audio.frame cimport AudioFrame -from av.audio.layout cimport AudioLayout -from av.error cimport err_check -from av.filter.context cimport FilterContext, wrap_filter_context -from av.filter.filter cimport Filter, wrap_filter -from av.video.format cimport VideoFormat -from av.video.frame cimport VideoFrame - - -cdef class Graph(object): - - def __cinit__(self): - - self.ptr = lib.avfilter_graph_alloc() - self.configured = False - self._name_counts = {} - - self._nb_filters_seen = 0 - self._context_by_ptr = {} - self._context_by_name = {} - self._context_by_type = {} - - def __dealloc__(self): - if self.ptr: - # This frees the graph, filter contexts, links, etc.. - lib.avfilter_graph_free(&self.ptr) - - cdef str _get_unique_name(self, str name): - count = self._name_counts.get(name, 0) - self._name_counts[name] = count + 1 - if count: - return '%s_%s' % (name, count) - else: - return name - - cpdef configure(self, bint auto_buffer=True, bint force=False): - if self.configured and not force: - return - - # if auto_buffer: - # for ctx in self._context_by_ptr.itervalues(): - # for in_ in ctx.inputs: - # if not in_.link: - # if in_.type == 'video': - # pass - - err_check(lib.avfilter_graph_config(self.ptr, NULL)) - self.configured = True - - # We get auto-inserted stuff here. - self._auto_register() - - # def parse_string(self, str filter_str): - # err_check(lib.avfilter_graph_parse2(self.ptr, filter_str, &self.inputs, &self.outputs)) - # - # cdef lib.AVFilterInOut *input_ - # while input_ != NULL: - # print 'in ', input_.pad_idx, (input_.name if input_.name != NULL else ''), input_.filter_ctx.name, input_.filter_ctx.filter.name - # input_ = input_.next - # - # cdef lib.AVFilterInOut *output - # while output != NULL: - # print 'out', output.pad_idx, (output.name if output.name != NULL else ''), output.filter_ctx.name, output.filter_ctx.filter.name - # output = output.next - - # NOTE: Only FFmpeg supports this. - # def dump(self): - # cdef char *buf = lib.avfilter_graph_dump(self.ptr, "") - # cdef str ret = buf - # lib.av_free(buf) - # return ret - - def add(self, filter, args=None, **kwargs): - - cdef Filter cy_filter - if isinstance(filter, str): - cy_filter = Filter(filter) - elif isinstance(filter, Filter): - cy_filter = filter - else: - raise TypeError("filter must be a string or Filter") - - cdef str name = self._get_unique_name(kwargs.pop('name', None) or cy_filter.name) - - cdef lib.AVFilterContext *ptr = lib.avfilter_graph_alloc_filter(self.ptr, cy_filter.ptr, name) - if not ptr: - raise RuntimeError("Could not allocate AVFilterContext") - - # Manually construct this context (so we can return it). - cdef FilterContext ctx = wrap_filter_context(self, cy_filter, ptr) - ctx.init(args, **kwargs) - self._register_context(ctx) - - # There might have been automatic contexts added (e.g. resamplers, - # fifos, and scalers). It is more likely to see them after the graph - # is configured, but we wan't to be safe. - self._auto_register() - - return ctx - - cdef _register_context(self, FilterContext ctx): - self._context_by_ptr[ctx.ptr] = ctx - self._context_by_name[ctx.ptr.name] = ctx - self._context_by_type.setdefault(ctx.filter.ptr.name, []).append(ctx) - - cdef _auto_register(self): - cdef int i - cdef lib.AVFilterContext *c_ctx - cdef Filter filter_ - cdef FilterContext py_ctx - # We assume that filters are never removed from the graph. At this - # point we don't expose that in the API, so we should be okay... - for i in range(self._nb_filters_seen, self.ptr.nb_filters): - c_ctx = self.ptr.filters[i] - if c_ctx in self._context_by_ptr: - continue - filter_ = wrap_filter(c_ctx.filter) - py_ctx = wrap_filter_context(self, filter_, c_ctx) - self._register_context(py_ctx) - self._nb_filters_seen = self.ptr.nb_filters - - def add_buffer(self, template=None, width=None, height=None, format=None, name=None): - - if template is not None: - if width is None: - width = template.width - if height is None: - height = template.height - if format is None: - format = template.format - - if width is None: - raise ValueError('missing width') - if height is None: - raise ValueError('missing height') - if format is None: - raise ValueError('missing format') - - return self.add( - 'buffer', - name=name, - video_size=f'{width}x{height}', - pix_fmt=str(int(VideoFormat(format))), - time_base='1/1000', - pixel_aspect='1/1', - ) - - def add_abuffer(self, template=None, sample_rate=None, format=None, layout=None, channels=None, name=None, time_base=None): - """ - Convenience method for adding `abuffer `_. - """ - - if template is not None: - if sample_rate is None: - sample_rate = template.sample_rate - if format is None: - format = template.format - if layout is None: - layout = template.layout.name - if channels is None: - channels = template.channels - if time_base is None: - time_base = template.time_base - - if sample_rate is None: - raise ValueError('missing sample_rate') - if format is None: - raise ValueError('missing format') - if layout is None and channels is None: - raise ValueError('missing layout or channels') - if time_base is None: - time_base = Fraction(1, sample_rate) - - kwargs = dict( - sample_rate=str(sample_rate), - sample_fmt=AudioFormat(format).name, - time_base=str(time_base), - ) - if layout: - kwargs['channel_layout'] = AudioLayout(layout).name - if channels: - kwargs['channels'] = str(channels) - - return self.add('abuffer', name=name, **kwargs) - - def push(self, frame): - - if isinstance(frame, VideoFrame): - contexts = self._context_by_type.get('buffer', []) - elif isinstance(frame, AudioFrame): - contexts = self._context_by_type.get('abuffer', []) - else: - raise ValueError('can only push VideoFrame or AudioFrame', type(frame)) - - if len(contexts) != 1: - raise ValueError('can only auto-push with single buffer; found %s' % len(contexts)) - - contexts[0].push(frame) - - def pull(self): - - vsinks = self._context_by_type.get('buffersink', []) - asinks = self._context_by_type.get('abuffersink', []) - - nsinks = len(vsinks) + len(asinks) - if nsinks != 1: - raise ValueError('can only auto-pull with single sink; found %s' % nsinks) - - return (vsinks or asinks)[0].pull() diff --git a/av/filter/link.pxd b/av/filter/link.pxd index 699838c38..7f6aad2aa 100644 --- a/av/filter/link.pxd +++ b/av/filter/link.pxd @@ -1,12 +1,13 @@ cimport libav as lib +from av.filter.context cimport FilterContext +from av.filter.filter cimport Filter from av.filter.graph cimport Graph -from av.filter.pad cimport FilterContextPad +from av.filter.link cimport FilterContextPad, FilterLink -cdef class FilterLink(object): - - cdef readonly Graph graph +cdef class FilterLink: + cdef object _graph cdef lib.AVFilterLink *ptr cdef FilterContextPad _input @@ -14,3 +15,18 @@ cdef class FilterLink(object): cdef FilterLink wrap_filter_link(Graph graph, lib.AVFilterLink *ptr) + +cdef class FilterPad: + cdef readonly Filter filter + cdef readonly FilterContext context + cdef readonly bint is_input + cdef readonly int index + + cdef const lib.AVFilterPad *base_ptr + + +cdef class FilterContextPad(FilterPad): + cdef FilterLink _link + + +cdef tuple alloc_filter_pads(Filter, const lib.AVFilterPad *ptr, bint is_input, FilterContext context=?) diff --git a/av/filter/link.py b/av/filter/link.py new file mode 100644 index 000000000..2b687e194 --- /dev/null +++ b/av/filter/link.py @@ -0,0 +1,159 @@ +import weakref + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.filter.graph import Graph + +_cinit_sentinel = cython.declare(object, object()) + + +@cython.final +@cython.cclass +class FilterLink: + def __cinit__(self, sentinel): + if sentinel is not _cinit_sentinel: + raise RuntimeError("cannot instantiate FilterLink") + + @property + def graph(self) -> Graph: + if g := self._graph(): + return g + else: + raise RuntimeError("graph is unallocated") + + @property + def input(self): + if self._input: + return self._input + cctx: cython.pointer[lib.AVFilterContext] = self.ptr.src + i: cython.Py_ssize_t + for i in range(cctx.nb_outputs): + if self.ptr == cctx.outputs[i]: + break + else: # nobreak + raise RuntimeError("could not find link in context") + graph: Graph = self.graph + ctx = graph._context_by_ptr[cython.cast(cython.size_t, cctx)] + self._input = ctx.outputs[i] + return self._input + + @property + def output(self): + if self._output: + return self._output + cctx: cython.pointer[lib.AVFilterContext] = self.ptr.dst + i: cython.Py_ssize_t + for i in range(cctx.nb_inputs): + if self.ptr == cctx.inputs[i]: + break + else: + raise RuntimeError("could not find link in context") + try: + graph: Graph = self.graph + ctx = graph._context_by_ptr[cython.cast(cython.size_t, cctx)] + except KeyError: + raise RuntimeError( + "could not find context in graph", (cctx.name, cctx.filter.name) + ) + self._output = ctx.inputs[i] + return self._output + + +@cython.cfunc +def wrap_filter_link(graph: Graph, ptr: cython.pointer[lib.AVFilterLink]) -> FilterLink: + link: FilterLink = FilterLink(_cinit_sentinel) + link._graph = weakref.ref(graph) + link.ptr = ptr + return link + + +@cython.cclass +class FilterPad: + def __cinit__(self, sentinel): + if sentinel is not _cinit_sentinel: + raise RuntimeError("cannot construct FilterPad") + + def __repr__(self): + _filter = self.filter.name + _io = "inputs" if self.is_input else "outputs" + + return ( + f"" + ) + + @property + def is_output(self): + return not self.is_input + + @property + def name(self): + return lib.avfilter_pad_get_name(self.base_ptr, self.index) + + +@cython.final +@cython.cclass +class FilterContextPad(FilterPad): + def __repr__(self): + _filter = self.filter.name + _io = "inputs" if self.is_input else "outputs" + context = self.context.name + + return f"" + + @property + def link(self): + if self._link: + return self._link + links: cython.pointer[cython.pointer[lib.AVFilterLink]] = ( + self.context.ptr.inputs if self.is_input else self.context.ptr.outputs + ) + link: cython.pointer[lib.AVFilterLink] = links[self.index] + if not link: + return + self._link = wrap_filter_link(self.context.graph, link) + return self._link + + @property + def linked(self): + link: FilterLink = self.link + if link: + return link.input if self.is_input else link.output + + +@cython.cfunc +def alloc_filter_pads( + filter: Filter, + ptr: cython.pointer[cython.const[lib.AVFilterPad]], + is_input: cython.bint, + context: FilterContext | None = None, +) -> tuple: + if not ptr: + return () + + pads: list = [] + + # We need to be careful and check our bounds if we know what they are, + # since the arrays on a AVFilterContext are not NULL terminated. + i: cython.int = 0 + count: cython.int + if context is None: + count = lib.avfilter_filter_pad_count(filter.ptr, not is_input) + else: + count = context.ptr.nb_inputs if is_input else context.ptr.nb_outputs + + pad: FilterPad + while i < count: + pad = ( + FilterPad(_cinit_sentinel) + if context is None + else FilterContextPad(_cinit_sentinel) + ) + pads.append(pad) + pad.filter = filter + pad.context = context + pad.is_input = is_input + pad.base_ptr = ptr + pad.index = i + i += 1 + + return tuple(pads) diff --git a/av/filter/link.pyi b/av/filter/link.pyi new file mode 100644 index 000000000..9d199a272 --- /dev/null +++ b/av/filter/link.pyi @@ -0,0 +1,2 @@ +class FilterLink: + pass diff --git a/av/filter/link.pyx b/av/filter/link.pyx deleted file mode 100644 index 62e6ff8bc..000000000 --- a/av/filter/link.pyx +++ /dev/null @@ -1,53 +0,0 @@ -cimport libav as lib - -from av.filter.graph cimport Graph - - -cdef _cinit_sentinel = object() - - -cdef class FilterLink(object): - - def __cinit__(self, sentinel): - if sentinel is not _cinit_sentinel: - raise RuntimeError('cannot instantiate FilterLink') - - property input: - def __get__(self): - if self._input: - return self._input - cdef lib.AVFilterContext *cctx = self.ptr.src - cdef unsigned int i - for i in range(cctx.nb_outputs): - if self.ptr == cctx.outputs[i]: - break - else: - raise RuntimeError('could not find link in context') - ctx = self.graph._context_by_ptr[cctx] - self._input = ctx.outputs[i] - return self._input - - property output: - def __get__(self): - if self._output: - return self._output - cdef lib.AVFilterContext *cctx = self.ptr.dst - cdef unsigned int i - for i in range(cctx.nb_inputs): - if self.ptr == cctx.inputs[i]: - break - else: - raise RuntimeError('could not find link in context') - try: - ctx = self.graph._context_by_ptr[cctx] - except KeyError: - raise RuntimeError('could not find context in graph', (cctx.name, cctx.filter.name)) - self._output = ctx.inputs[i] - return self._output - - -cdef FilterLink wrap_filter_link(Graph graph, lib.AVFilterLink *ptr): - cdef FilterLink link = FilterLink(_cinit_sentinel) - link.graph = graph - link.ptr = ptr - return link diff --git a/av/filter/loudnorm.pxd b/av/filter/loudnorm.pxd new file mode 100644 index 000000000..2729dd8be --- /dev/null +++ b/av/filter/loudnorm.pxd @@ -0,0 +1,19 @@ +from av.audio.stream cimport AudioStream + + +cdef extern from "libavcodec/avcodec.h": + ctypedef struct AVCodecContext: + pass + +cdef extern from "libavformat/avformat.h": + ctypedef struct AVFormatContext: + pass + +cdef extern from "loudnorm_impl.h": + char* loudnorm_get_stats( + AVFormatContext* fmt_ctx, + int audio_stream_index, + const char* loudnorm_args + ) nogil + +cpdef bytes stats(str loudnorm_args, AudioStream stream) diff --git a/av/filter/loudnorm.py b/av/filter/loudnorm.py new file mode 100644 index 000000000..0be991ab6 --- /dev/null +++ b/av/filter/loudnorm.py @@ -0,0 +1,48 @@ +import cython +from cython.cimports.av.audio.stream import AudioStream +from cython.cimports.av.container.core import Container +from cython.cimports.libc.stdlib import free + +from av.logging import get_level, set_level + + +@cython.ccall +def stats(loudnorm_args: str, stream: AudioStream) -> bytes: + """ + Get loudnorm statistics for an audio stream. + + Args: + loudnorm_args (str): Arguments for the loudnorm filter (e.g. "i=-24.0:lra=7.0:tp=-2.0") + stream (AudioStream): Input audio stream to analyze + + Returns: + bytes: JSON string containing the loudnorm statistics + """ + + if "print_format=json" not in loudnorm_args: + loudnorm_args = loudnorm_args + ":print_format=json" + + container: Container = stream.container + format_ptr: cython.pointer[AVFormatContext] = container.ptr + container.ptr = cython.NULL # Prevent double-free + + stream_index: cython.int = stream.index + py_args: bytes = loudnorm_args.encode("utf-8") + c_args: cython.p_const_char = py_args + result: cython.p_char + + # Save log level since C function overwrite it. + level = get_level() + + with cython.nogil: + result = loudnorm_get_stats(format_ptr, stream_index, c_args) + + if result == cython.NULL: + raise RuntimeError("Failed to get loudnorm stats") + + py_result = result[:] # Make a copy of the string + free(result) # Free the C string + + set_level(level) + + return py_result diff --git a/av/filter/loudnorm.pyi b/av/filter/loudnorm.pyi new file mode 100644 index 000000000..c680f638d --- /dev/null +++ b/av/filter/loudnorm.pyi @@ -0,0 +1,3 @@ +from av.audio.stream import AudioStream + +def stats(loudnorm_args: str, stream: AudioStream) -> bytes: ... diff --git a/av/filter/loudnorm_impl.c b/av/filter/loudnorm_impl.c new file mode 100644 index 000000000..5d43c28b8 --- /dev/null +++ b/av/filter/loudnorm_impl.c @@ -0,0 +1,166 @@ +#include +#include +#include +#include +#include +#include +#include + +static char json_buffer[2048] = {0}; +static int json_captured = 0; + +// Custom logging callback. The loudnorm filter prints its stats as a JSON line. +static void logging_callback(void *ptr, int level, const char *fmt, va_list vl) { + char line[2048]; + vsnprintf(line, sizeof(line), fmt, vl); + + const char *json_start = strstr(line, "{"); + if (json_start) { + size_t len = strnlen(json_start, sizeof(json_buffer) - 1); + memcpy(json_buffer, json_start, len); + json_buffer[len] = '\0'; + json_captured = 1; + } +} + +char* loudnorm_get_stats( + AVFormatContext* fmt_ctx, + int audio_stream_index, + const char* loudnorm_args +) { + char* result = NULL; + + // Bail out cleanly if FFmpeg was built without a filter we depend on, + // instead of dereferencing a NULL filter context further down. The caller + // turns a NULL return into a Python exception. + if (!avfilter_get_by_name("abuffer") || + !avfilter_get_by_name("abuffersink") || + !avfilter_get_by_name("loudnorm")) { + av_log(NULL, AV_LOG_ERROR, + "loudnorm: a required filter (abuffer/abuffersink/loudnorm) is not " + "available in this FFmpeg build\n"); + avformat_close_input(&fmt_ctx); + return NULL; + } + + json_captured = 0; // Reset the captured flag + memset(json_buffer, 0, sizeof(json_buffer)); // Clear the buffer + + av_log_set_callback(logging_callback); + + AVFilterGraph *filter_graph = NULL; + AVFilterContext *src_ctx = NULL, *sink_ctx = NULL, *loudnorm_ctx = NULL; + + AVCodec *codec = NULL; + AVCodecContext *codec_ctx = NULL; + AVPacket *packet = NULL; + AVFrame *frame = NULL, *filt_frame = NULL; + int ret; + int graph_configured = 0; + + AVCodecParameters *codecpar = fmt_ctx->streams[audio_stream_index]->codecpar; + codec = (AVCodec *)avcodec_find_decoder(codecpar->codec_id); + codec_ctx = avcodec_alloc_context3(codec); + avcodec_parameters_to_context(codec_ctx, codecpar); + avcodec_open2(codec_ctx, codec, NULL); + + char ch_layout_str[64]; + av_channel_layout_describe(&codecpar->ch_layout, ch_layout_str, sizeof(ch_layout_str)); + + filter_graph = avfilter_graph_alloc(); + + char args[512]; + snprintf(args, sizeof(args), + "time_base=%d/%d:sample_rate=%d:sample_fmt=%s:channel_layout=%s", + fmt_ctx->streams[audio_stream_index]->time_base.num, + fmt_ctx->streams[audio_stream_index]->time_base.den, + codecpar->sample_rate, + av_get_sample_fmt_name(codec_ctx->sample_fmt), + ch_layout_str); + + if (avfilter_graph_create_filter(&src_ctx, avfilter_get_by_name("abuffer"), + "src", args, NULL, filter_graph) < 0 || + avfilter_graph_create_filter(&sink_ctx, avfilter_get_by_name("abuffersink"), + "sink", NULL, NULL, filter_graph) < 0 || + avfilter_graph_create_filter(&loudnorm_ctx, avfilter_get_by_name("loudnorm"), + "loudnorm", loudnorm_args, NULL, filter_graph) < 0) { + goto end; + } + + if (avfilter_link(src_ctx, 0, loudnorm_ctx, 0) < 0 || + avfilter_link(loudnorm_ctx, 0, sink_ctx, 0) < 0 || + avfilter_graph_config(filter_graph, NULL) < 0) { + goto end; + } + graph_configured = 1; + + packet = av_packet_alloc(); + frame = av_frame_alloc(); + filt_frame = av_frame_alloc(); + + while ((ret = av_read_frame(fmt_ctx, packet)) >= 0) { + if (packet->stream_index != audio_stream_index) { + av_packet_unref(packet); + continue; + } + + ret = avcodec_send_packet(codec_ctx, packet); + if (ret < 0) { + av_packet_unref(packet); + continue; + } + + while (ret >= 0) { + ret = avcodec_receive_frame(codec_ctx, frame); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; + if (ret < 0) goto end; + + ret = av_buffersrc_add_frame_flags(src_ctx, frame, AV_BUFFERSRC_FLAG_KEEP_REF); + if (ret < 0) goto end; + + while (1) { + ret = av_buffersink_get_frame(sink_ctx, filt_frame); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; + if (ret < 0) goto end; + av_frame_unref(filt_frame); + } + } + av_packet_unref(packet); + } + + // Flush decoder + avcodec_send_packet(codec_ctx, NULL); + while (avcodec_receive_frame(codec_ctx, frame) >= 0) { + ret = av_buffersrc_add_frame(src_ctx, frame); + if (ret < 0) goto end; + } + + // Flush filter + ret = av_buffersrc_add_frame(src_ctx, NULL); + if (ret < 0) goto end; + while (av_buffersink_get_frame(sink_ctx, filt_frame) >= 0) { + av_frame_unref(filt_frame); + } + +end: + // Freeing the graph uninits the loudnorm filter, which is what makes it + // emit its JSON stats through our log callback. This happens synchronously + // on this thread, so json_buffer is populated by the time the call returns. + avfilter_graph_free(&filter_graph); + avcodec_free_context(&codec_ctx); + avformat_close_input(&fmt_ctx); + av_frame_free(&filt_frame); + av_frame_free(&frame); + av_packet_free(&packet); + + if (graph_configured && json_captured) { +#ifdef _WIN32 + result = _strdup(json_buffer); +#else + result = strdup(json_buffer); +#endif + } + + av_log_set_callback(av_log_default_callback); + return result; +} diff --git a/av/filter/loudnorm_impl.h b/av/filter/loudnorm_impl.h new file mode 100644 index 000000000..7357e4668 --- /dev/null +++ b/av/filter/loudnorm_impl.h @@ -0,0 +1,12 @@ +#ifndef AV_FILTER_LOUDNORM_H +#define AV_FILTER_LOUDNORM_H + +#include + +char* loudnorm_get_stats( + AVFormatContext* fmt_ctx, + int audio_stream_index, + const char* loudnorm_args +); + +#endif // AV_FILTER_LOUDNORM_H \ No newline at end of file diff --git a/av/filter/pad.pxd b/av/filter/pad.pxd deleted file mode 100644 index 088c0b0c0..000000000 --- a/av/filter/pad.pxd +++ /dev/null @@ -1,23 +0,0 @@ -cimport libav as lib - -from av.filter.context cimport FilterContext -from av.filter.filter cimport Filter -from av.filter.link cimport FilterLink - - -cdef class FilterPad(object): - - cdef readonly Filter filter - cdef readonly FilterContext context - cdef readonly bint is_input - cdef readonly int index - - cdef const lib.AVFilterPad *base_ptr - - -cdef class FilterContextPad(FilterPad): - - cdef FilterLink _link - - -cdef tuple alloc_filter_pads(Filter, const lib.AVFilterPad *ptr, bint is_input, FilterContext context=?) diff --git a/av/filter/pad.pyx b/av/filter/pad.pyx deleted file mode 100644 index 9b2c6b853..000000000 --- a/av/filter/pad.pyx +++ /dev/null @@ -1,96 +0,0 @@ -from av.filter.link cimport wrap_filter_link - - -cdef object _cinit_sentinel = object() - - -cdef class FilterPad(object): - - def __cinit__(self, sentinel): - if sentinel is not _cinit_sentinel: - raise RuntimeError('cannot construct FilterPad') - - def __repr__(self): - return '' % ( - self.filter.name, - 'inputs' if self.is_input else 'outputs', - self.index, - self.name, - self.type, - ) - - property is_output: - def __get__(self): - return not self.is_input - - property name: - def __get__(self): - return lib.avfilter_pad_get_name(self.base_ptr, self.index) - - @property - def type(self): - """ - The media type of this filter pad. - - Examples: `'audio'`, `'video'`, `'subtitle'`. - - :type: str - """ - return lib.av_get_media_type_string(lib.avfilter_pad_get_type(self.base_ptr, self.index)) - - -cdef class FilterContextPad(FilterPad): - - def __repr__(self): - - return '' % ( - self.filter.name, - 'inputs' if self.is_input else 'outputs', - self.index, - self.context.name, - self.name, - self.type, - ) - - property link: - def __get__(self): - if self._link: - return self._link - cdef lib.AVFilterLink **links = self.context.ptr.inputs if self.is_input else self.context.ptr.outputs - cdef lib.AVFilterLink *link = links[self.index] - if not link: - return - self._link = wrap_filter_link(self.context.graph, link) - return self._link - - property linked: - def __get__(self): - cdef FilterLink link = self.link - if link: - return link.input if self.is_input else link.output - - -cdef tuple alloc_filter_pads(Filter filter, const lib.AVFilterPad *ptr, bint is_input, FilterContext context=None): - - if not ptr: - return () - - pads = [] - - # We need to be careful and check our bounds if we know what they are, - # since the arrays on a AVFilterContext are not NULL terminated. - cdef int i = 0 - cdef int count = (context.ptr.nb_inputs if is_input else context.ptr.nb_outputs) if context is not None else -1 - - cdef FilterPad pad - while (i < count or count < 0) and lib.avfilter_pad_get_name(ptr, i): - pad = FilterPad(_cinit_sentinel) if context is None else FilterContextPad(_cinit_sentinel) - pads.append(pad) - pad.filter = filter - pad.context = context - pad.is_input = is_input - pad.base_ptr = ptr - pad.index = i - i += 1 - - return tuple(pads) diff --git a/av/format.pxd b/av/format.pxd index 8165daa4e..4109f325c 100644 --- a/av/format.pxd +++ b/av/format.pxd @@ -1,12 +1,12 @@ cimport libav as lib -cdef class ContainerFormat(object): +cdef class ContainerFormat: cdef readonly str name - cdef lib.AVInputFormat *iptr - cdef lib.AVOutputFormat *optr + cdef const lib.AVInputFormat *iptr + cdef const lib.AVOutputFormat *optr -cdef ContainerFormat build_container_format(lib.AVInputFormat*, lib.AVOutputFormat*) +cdef ContainerFormat build_container_format(const lib.AVInputFormat*, const lib.AVOutputFormat*) diff --git a/av/format.py b/av/format.py new file mode 100644 index 000000000..fec69f7a0 --- /dev/null +++ b/av/format.py @@ -0,0 +1,168 @@ +from enum import Flag + +import cython +import cython.cimports.libav as lib + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def build_container_format( + iptr: cython.pointer[cython.const[lib.AVInputFormat]], + optr: cython.pointer[cython.const[lib.AVOutputFormat]], +) -> ContainerFormat: + if not iptr and not optr: + raise ValueError("needs input format or output format") + format: ContainerFormat = ContainerFormat.__new__( + ContainerFormat, _cinit_bypass_sentinel + ) + format.iptr = iptr + format.optr = optr + format.name = optr.name if optr else iptr.name + return format + + +# fmt: off +class Flags(Flag): + no_file = lib.AVFMT_NOFILE + need_number: "Needs '%d' in filename." = lib.AVFMT_NEEDNUMBER + show_ids: "Show format stream IDs numbers." = lib.AVFMT_SHOW_IDS + global_header: "Format wants global header." = lib.AVFMT_GLOBALHEADER + no_timestamps: "Format does not need / have any timestamps." = lib.AVFMT_NOTIMESTAMPS + generic_index: "Use generic index building code." = lib.AVFMT_GENERIC_INDEX + ts_discont: "Format allows timestamp discontinuities" = lib.AVFMT_TS_DISCONT + variable_fps: "Format allows variable fps." = lib.AVFMT_VARIABLE_FPS + no_dimensions: "Format does not need width/height" = lib.AVFMT_NODIMENSIONS + no_streams: "Format does not require any streams" = lib.AVFMT_NOSTREAMS + no_bin_search: "Format does not allow to fall back on binary search via read_timestamp" = lib.AVFMT_NOBINSEARCH + no_gen_search: "Format does not allow to fall back on generic search" = lib.AVFMT_NOGENSEARCH + no_byte_seek: "Format does not allow seeking by bytes" = lib.AVFMT_NO_BYTE_SEEK + ts_nonstrict: "Format does not require strictly increasing timestamps, but they must still be monotonic." = lib.AVFMT_TS_NONSTRICT + ts_negative: "Format allows muxing negative timestamps." = lib.AVFMT_TS_NEGATIVE + # If not set the timestamp will be shifted in `av_write_frame()` and `av_interleaved_write_frame()` + # so they start from 0. The user or muxer can override this through AVFormatContext.avoid_negative_ts + seek_to_pts: "Seeking is based on PTS" = lib.AVFMT_SEEK_TO_PTS +# fmt: on + + +@cython.final +@cython.cclass +class ContainerFormat: + """Descriptor of a container format. + + :param str name: The name of the format. + :param str mode: ``'r'`` or ``'w'`` for input and output formats; defaults + to None which will grab either. + + """ + + def __cinit__(self, name, mode=None): + if name is _cinit_bypass_sentinel: + return + + # We need to hold onto the original name because AVInputFormat.name is + # actually comma-separated, and so we need to remember which one this was. + self.name = name + + # Searches comma-separated names. + if mode is None or mode == "r": + self.iptr = lib.av_find_input_format(name) + + if mode is None or mode == "w": + self.optr = lib.av_guess_format(name, cython.NULL, cython.NULL) + + if not self.iptr and not self.optr: + raise ValueError(f"no container format {name!r}") + + def __repr__(self): + return f"" + + @property + def input(self): + """An input-only view of this format.""" + if self.iptr == cython.NULL: + return None + elif self.optr == cython.NULL: + return self + else: + return build_container_format(self.iptr, cython.NULL) + + @property + def output(self): + """An output-only view of this format.""" + if self.optr == cython.NULL: + return None + elif self.iptr == cython.NULL: + return self + else: + return build_container_format(cython.NULL, self.optr) + + @property + def is_input(self): + return self.iptr != cython.NULL + + @property + def is_output(self): + return self.optr != cython.NULL + + @property + def long_name(self): + # We prefer the output names since the inputs may represent + # multiple formats. + return self.optr.long_name if self.optr else self.iptr.long_name + + @property + def extensions(self): + exts: set = set() + if self.iptr and self.iptr.extensions: + exts.update(self.iptr.extensions.split(",")) + if self.optr and self.optr.extensions: + exts.update(self.optr.extensions.split(",")) + return exts + + @property + def flags(self): + """ + Get the flags bitmask for the format. + + :rtype: int + """ + return (self.iptr.flags if self.iptr else 0) | ( + self.optr.flags if self.optr else 0 + ) + + @property + def no_file(self): + return bool(self.flags & lib.AVFMT_NOFILE) + + +@cython.cfunc +def get_output_format_names() -> set: + names: set = set() + ptr: cython.pointer[cython.const[lib.AVOutputFormat]] + opaque: cython.p_void = cython.NULL + while True: + ptr = lib.av_muxer_iterate(cython.address(opaque)) + if ptr: + names.add(ptr.name) + else: + break + return names + + +@cython.cfunc +def get_input_format_names() -> set: + names: set = set() + ptr: cython.pointer[cython.const[lib.AVInputFormat]] + opaque: cython.p_void = cython.NULL + while True: + ptr = lib.av_demuxer_iterate(cython.address(opaque)) + if ptr: + names.add(ptr.name) + else: + break + return names + + +formats_available = get_output_format_names() +formats_available.update(get_input_format_names()) diff --git a/av/format.pyi b/av/format.pyi new file mode 100644 index 000000000..81c5c42ca --- /dev/null +++ b/av/format.pyi @@ -0,0 +1,42 @@ +__all__ = ("Flags", "ContainerFormat", "formats_available") + +from enum import Flag +from typing import ClassVar, Literal, cast + +class Flags(Flag): + no_file = cast(ClassVar[Flags], ...) + need_number = cast(ClassVar[Flags], ...) + show_ids = cast(ClassVar[Flags], ...) + global_header = cast(ClassVar[Flags], ...) + no_timestamps = cast(ClassVar[Flags], ...) + generic_index = cast(ClassVar[Flags], ...) + ts_discont = cast(ClassVar[Flags], ...) + variable_fps = cast(ClassVar[Flags], ...) + no_dimensions = cast(ClassVar[Flags], ...) + no_streams = cast(ClassVar[Flags], ...) + no_bin_search = cast(ClassVar[Flags], ...) + no_gen_search = cast(ClassVar[Flags], ...) + no_byte_seek = cast(ClassVar[Flags], ...) + allow_flush = cast(ClassVar[Flags], ...) + ts_nonstrict = cast(ClassVar[Flags], ...) + ts_negative = cast(ClassVar[Flags], ...) + seek_to_pts = cast(ClassVar[Flags], ...) + +class ContainerFormat: + def __init__(self, name: str, mode: Literal["r", "w"] | None = None) -> None: ... + @property + def name(self) -> str: ... + @property + def long_name(self) -> str: ... + @property + def is_input(self) -> bool: ... + @property + def is_output(self) -> bool: ... + @property + def extensions(self) -> set[str]: ... + @property + def flags(self) -> int: ... + @property + def no_file(self) -> bool: ... + +formats_available: set[str] diff --git a/av/format.pyx b/av/format.pyx deleted file mode 100644 index 05a5402a8..000000000 --- a/av/format.pyx +++ /dev/null @@ -1,203 +0,0 @@ -cimport libav as lib - -from av.descriptor cimport wrap_avclass -from av.enum cimport define_enum - - -cdef object _cinit_bypass_sentinel = object() - -cdef ContainerFormat build_container_format(lib.AVInputFormat* iptr, lib.AVOutputFormat* optr): - if not iptr and not optr: - raise ValueError('needs input format or output format') - cdef ContainerFormat format = ContainerFormat.__new__(ContainerFormat, _cinit_bypass_sentinel) - format.iptr = iptr - format.optr = optr - format.name = optr.name if optr else iptr.name - return format - - -Flags = define_enum('Flags', __name__, ( - ('NOFILE', lib.AVFMT_NOFILE), - ('NEEDNUMBER', lib.AVFMT_NEEDNUMBER, - """Needs '%d' in filename."""), - ('SHOW_IDS', lib.AVFMT_SHOW_IDS, - """Show format stream IDs numbers."""), - ('GLOBALHEADER', lib.AVFMT_GLOBALHEADER, - """Format wants global header."""), - ('NOTIMESTAMPS', lib.AVFMT_NOTIMESTAMPS, - """Format does not need / have any timestamps."""), - ('GENERIC_INDEX', lib.AVFMT_GENERIC_INDEX, - """Use generic index building code."""), - ('TS_DISCONT', lib.AVFMT_TS_DISCONT, - """Format allows timestamp discontinuities. - Note, muxers always require valid (monotone) timestamps"""), - ('VARIABLE_FPS', lib.AVFMT_VARIABLE_FPS, - """Format allows variable fps."""), - ('NODIMENSIONS', lib.AVFMT_NODIMENSIONS, - """Format does not need width/height"""), - ('NOSTREAMS', lib.AVFMT_NOSTREAMS, - """Format does not require any streams"""), - ('NOBINSEARCH', lib.AVFMT_NOBINSEARCH, - """Format does not allow to fall back on binary search via read_timestamp"""), - ('NOGENSEARCH', lib.AVFMT_NOGENSEARCH, - """Format does not allow to fall back on generic search"""), - ('NO_BYTE_SEEK', lib.AVFMT_NO_BYTE_SEEK, - """Format does not allow seeking by bytes"""), - ('ALLOW_FLUSH', lib.AVFMT_ALLOW_FLUSH, - """Format allows flushing. If not set, the muxer will not receive a NULL - packet in the write_packet function."""), - ('TS_NONSTRICT', lib.AVFMT_TS_NONSTRICT, - """Format does not require strictly increasing timestamps, but they must - still be monotonic."""), - ('TS_NEGATIVE', lib.AVFMT_TS_NEGATIVE, - """Format allows muxing negative timestamps. If not set the timestamp - will be shifted in av_write_frame and av_interleaved_write_frame so they - start from 0. The user or muxer can override this through - AVFormatContext.avoid_negative_ts"""), - ('SEEK_TO_PTS', lib.AVFMT_SEEK_TO_PTS, - """Seeking is based on PTS"""), -), is_flags=True) - - -cdef class ContainerFormat(object): - - """Descriptor of a container format. - - :param str name: The name of the format. - :param str mode: ``'r'`` or ``'w'`` for input and output formats; defaults - to None which will grab either. - - """ - - def __cinit__(self, name, mode=None): - - if name is _cinit_bypass_sentinel: - return - - # We need to hold onto the original name because AVInputFormat.name - # is actually comma-seperated, and so we need to remember which one - # this was. - self.name = name - - # Searches comma-seperated names. - if mode is None or mode == 'r': - self.iptr = lib.av_find_input_format(name) - - if mode is None or mode == 'w': - self.optr = lib.av_guess_format(name, NULL, NULL) - - if not self.iptr and not self.optr: - raise ValueError('no container format %r' % name) - - def __repr__(self): - return '' % (self.__class__.__name__, self.name) - - property descriptor: - def __get__(self): - if self.iptr: - return wrap_avclass(self.iptr.priv_class) - else: - return wrap_avclass(self.optr.priv_class) - - property options: - def __get__(self): - return self.descriptor.options - - property input: - """An input-only view of this format.""" - def __get__(self): - if self.iptr == NULL: - return None - elif self.optr == NULL: - return self - else: - return build_container_format(self.iptr, NULL) - - property output: - """An output-only view of this format.""" - def __get__(self): - if self.optr == NULL: - return None - elif self.iptr == NULL: - return self - else: - return build_container_format(NULL, self.optr) - - property is_input: - def __get__(self): - return self.iptr != NULL - - property is_output: - def __get__(self): - return self.optr != NULL - - property long_name: - def __get__(self): - # We prefer the output names since the inputs may represent - # multiple formats. - return self.optr.long_name if self.optr else self.iptr.long_name - - property extensions: - def __get__(self): - cdef set exts = set() - if self.iptr and self.iptr.extensions: - exts.update(self.iptr.extensions.split(',')) - if self.optr and self.optr.extensions: - exts.update(self.optr.extensions.split(',')) - return exts - - @Flags.property - def flags(self): - return ( - (self.iptr.flags if self.iptr else 0) | - (self.optr.flags if self.optr else 0) - ) - - no_file = flags.flag_property('NOFILE') - need_number = flags.flag_property('NEEDNUMBER') - show_ids = flags.flag_property('SHOW_IDS') - global_header = flags.flag_property('GLOBALHEADER') - no_timestamps = flags.flag_property('NOTIMESTAMPS') - generic_index = flags.flag_property('GENERIC_INDEX') - ts_discont = flags.flag_property('TS_DISCONT') - variable_fps = flags.flag_property('VARIABLE_FPS') - no_dimensions = flags.flag_property('NODIMENSIONS') - no_streams = flags.flag_property('NOSTREAMS') - no_bin_search = flags.flag_property('NOBINSEARCH') - no_gen_search = flags.flag_property('NOGENSEARCH') - no_byte_seek = flags.flag_property('NO_BYTE_SEEK') - allow_flush = flags.flag_property('ALLOW_FLUSH') - ts_nonstrict = flags.flag_property('TS_NONSTRICT') - ts_negative = flags.flag_property('TS_NEGATIVE') - seek_to_pts = flags.flag_property('SEEK_TO_PTS') - - -cdef get_output_format_names(): - names = set() - cdef const lib.AVOutputFormat *ptr - cdef void *opaque = NULL - while True: - ptr = lib.av_muxer_iterate(&opaque) - if ptr: - names.add(ptr.name) - else: - break - return names - -cdef get_input_format_names(): - names = set() - cdef const lib.AVInputFormat *ptr - cdef void *opaque = NULL - while True: - ptr = lib.av_demuxer_iterate(&opaque) - if ptr: - names.add(ptr.name) - else: - break - return names - -formats_available = get_output_format_names() -formats_available.update(get_input_format_names()) - - -format_descriptor = wrap_avclass(lib.avformat_get_class()) diff --git a/av/frame.pxd b/av/frame.pxd index e0d5b4280..6d7214b7c 100644 --- a/av/frame.pxd +++ b/av/frame.pxd @@ -4,18 +4,11 @@ from av.packet cimport Packet from av.sidedata.sidedata cimport _SideDataContainer -cdef class Frame(object): - +cdef class Frame: cdef lib.AVFrame *ptr - # We define our own time. cdef lib.AVRational _time_base cdef _rebase_time(self, lib.AVRational) - cdef _SideDataContainer _side_data - - cdef readonly int index - cdef _copy_internal_attributes(self, Frame source, bint data_layout=?) - cdef _init_user_attributes(self) diff --git a/av/frame.py b/av/frame.py new file mode 100644 index 000000000..7c54ae2e2 --- /dev/null +++ b/av/frame.py @@ -0,0 +1,210 @@ +import cython +from cython.cimports.av.error import err_check +from cython.cimports.av.opaque import opaque_container +from cython.cimports.av.utils import ( + avdict_to_dict, + avrational_to_fraction, + to_avrational, +) + +from av.sidedata.sidedata import SideDataContainer + + +@cython.cclass +class Frame: + """ + Base class for audio and video frames. + + See also :class:`~av.audio.frame.AudioFrame` and :class:`~av.video.frame.VideoFrame`. + """ + + def __cinit__(self, *args, **kwargs): + with cython.nogil: + self.ptr = lib.av_frame_alloc() + + def __dealloc__(self): + with cython.nogil: + lib.av_frame_free(cython.address(self.ptr)) + + def __repr__(self): + return f"" + + @cython.cfunc + def _copy_internal_attributes(self, source: Frame, data_layout: cython.bint = True): + # Mimic another frame + self._time_base = source._time_base + lib.av_frame_copy_props(self.ptr, source.ptr) + if data_layout: + # TODO: Assert we don't have any data yet. + self.ptr.format = source.ptr.format + self.ptr.width = source.ptr.width + self.ptr.height = source.ptr.height + self.ptr.ch_layout = source.ptr.ch_layout + + @cython.cfunc + def _init_user_attributes(self): + pass # Dummy to match the API of the others. + + @cython.cfunc + def _rebase_time(self, dst: lib.AVRational): + if not dst.num: + raise ValueError("Cannot rebase to zero time.") + + if not self._time_base.num: + self._time_base = dst + return + + if self._time_base.num == dst.num and self._time_base.den == dst.den: + return + + if self.ptr.pts != lib.AV_NOPTS_VALUE: + self.ptr.pts = lib.av_rescale_q(self.ptr.pts, self._time_base, dst) + + if self.ptr.duration != 0: + self.ptr.duration = lib.av_rescale_q( + self.ptr.duration, self._time_base, dst + ) + + self._time_base = dst + + @property + def dts(self): + """ + The decoding timestamp copied from the :class:`~av.packet.Packet` that triggered returning this frame in :attr:`time_base` units. + + (if frame threading isn't used) This is also the Presentation time of this frame calculated from only :attr:`.Packet.dts` values without pts values. + + :type: int | None + """ + if self.ptr.pkt_dts == lib.AV_NOPTS_VALUE: + return None + return self.ptr.pkt_dts + + @dts.setter + def dts(self, value): + if value is None: + self.ptr.pkt_dts = lib.AV_NOPTS_VALUE + else: + self.ptr.pkt_dts = value + + @property + def pts(self): + """ + The presentation timestamp in :attr:`time_base` units for this frame. + + This is the time at which the frame should be shown to the user. + + :type: int | None + """ + if self.ptr.pts == lib.AV_NOPTS_VALUE: + return None + return self.ptr.pts + + @pts.setter + def pts(self, value): + if value is None: + self.ptr.pts = lib.AV_NOPTS_VALUE + else: + self.ptr.pts = value + + @property + def duration(self): + """ + The duration of the frame in :attr:`time_base` units + + :type: int + """ + return self.ptr.duration + + @duration.setter + def duration(self, value): + self.ptr.duration = value + + @property + @cython.cdivision(True) + def time(self): + """ + The presentation time in seconds for this frame. + + This is the time at which the frame should be shown to the user. + + :type: float | None + """ + if self.ptr.pts == lib.AV_NOPTS_VALUE: + return None + return float(self.ptr.pts) * self._time_base.num / self._time_base.den + + @property + def time_base(self): + """ + The unit of time (in fractional seconds) in which timestamps are expressed. + + :type: fractions.Fraction | None + """ + if self._time_base.num: + return avrational_to_fraction(cython.address(self._time_base)) + + @time_base.setter + def time_base(self, value): + to_avrational(value, cython.address(self._time_base)) + + @property + def is_corrupt(self): + """ + Is this frame corrupt? + + :type: bool + """ + return self.ptr.decode_error_flags != 0 or bool( + self.ptr.flags & lib.AV_FRAME_FLAG_CORRUPT + ) + + @property + def key_frame(self): + """Is this frame a key frame? + + Wraps :ffmpeg:`AVFrame.key_frame`. + + """ + return bool(self.ptr.flags & lib.AV_FRAME_FLAG_KEY) + + @key_frame.setter + def key_frame(self, v): + # PyAV makes no guarantees this does anything. + if v: + self.ptr.flags |= lib.AV_FRAME_FLAG_KEY + else: + self.ptr.flags &= ~lib.AV_FRAME_FLAG_KEY + + @property + def side_data(self): + if self._side_data is None: + self._side_data = SideDataContainer(self) + return self._side_data + + @property + def metadata(self): + """Metadata attached to the frame by FFmpeg.""" + return avdict_to_dict(self.ptr.metadata, "utf-8", "strict") + + def make_writable(self): + """ + Ensures that the frame data is writable. Copy the data to new buffer if it is not. + This is a wrapper around :ffmpeg:`av_frame_make_writable`. + """ + ret: cython.int = lib.av_frame_make_writable(self.ptr) + err_check(ret) + + @property + def opaque(self): + if self.ptr.opaque_ref is not cython.NULL: + return opaque_container.get( + cython.cast(cython.p_char, self.ptr.opaque_ref.data) + ) + + @opaque.setter + def opaque(self, v): + lib.av_buffer_unref(cython.address(self.ptr.opaque_ref)) + + if v is not None: + self.ptr.opaque_ref = opaque_container.add(v) diff --git a/av/frame.pyi b/av/frame.pyi new file mode 100644 index 000000000..caed23482 --- /dev/null +++ b/av/frame.pyi @@ -0,0 +1,24 @@ +from fractions import Fraction +from typing import TypedDict + +from av.sidedata.motionvectors import MotionVectors + +class SideData(TypedDict, total=False): + MOTION_VECTORS: MotionVectors + +class Frame: + dts: int | None + pts: int | None + duration: int + time_base: Fraction | None + side_data: SideData + opaque: object + @property + def metadata(self) -> dict[str, str]: ... + @property + def time(self) -> float | None: ... + @property + def is_corrupt(self) -> bool: ... + @property + def key_frame(self) -> bool: ... + def make_writable(self) -> None: ... diff --git a/av/frame.pyx b/av/frame.pyx deleted file mode 100644 index 3717ddc81..000000000 --- a/av/frame.pyx +++ /dev/null @@ -1,138 +0,0 @@ -from av.utils cimport avrational_to_fraction, to_avrational - -from fractions import Fraction - -from av.sidedata.sidedata import SideDataContainer - - -cdef class Frame(object): - """ - Base class for audio and video frames. - - See also :class:`~av.audio.frame.AudioFrame` and :class:`~av.video.frame.VideoFrame`. - """ - - def __cinit__(self, *args, **kwargs): - with nogil: - self.ptr = lib.av_frame_alloc() - - def __dealloc__(self): - with nogil: - # This calls av_frame_unref, and then frees the pointer. - # Thats it. - lib.av_frame_free(&self.ptr) - - def __repr__(self): - return 'av.%s #%d pts=%s at 0x%x>' % ( - self.__class__.__name__, - self.index, - self.pts, - id(self), - ) - - cdef _copy_internal_attributes(self, Frame source, bint data_layout=True): - """Mimic another frame.""" - self.index = source.index - self._time_base = source._time_base - lib.av_frame_copy_props(self.ptr, source.ptr) - if data_layout: - # TODO: Assert we don't have any data yet. - self.ptr.format = source.ptr.format - self.ptr.width = source.ptr.width - self.ptr.height = source.ptr.height - self.ptr.channel_layout = source.ptr.channel_layout - self.ptr.channels = source.ptr.channels - - cdef _init_user_attributes(self): - pass # Dummy to match the API of the others. - - cdef _rebase_time(self, lib.AVRational dst): - - if not dst.num: - raise ValueError('Cannot rebase to zero time.') - - if not self._time_base.num: - self._time_base = dst - return - - if self._time_base.num == dst.num and self._time_base.den == dst.den: - return - - if self.ptr.pts != lib.AV_NOPTS_VALUE: - self.ptr.pts = lib.av_rescale_q( - self.ptr.pts, - self._time_base, dst - ) - - self._time_base = dst - - property dts: - """ - The decoding timestamp in :attr:`time_base` units for this frame. - - :type: int - """ - def __get__(self): - if self.ptr.pkt_dts == lib.AV_NOPTS_VALUE: - return None - return self.ptr.pkt_dts - - property pts: - """ - The presentation timestamp in :attr:`time_base` units for this frame. - - This is the time at which the frame should be shown to the user. - - :type: int - """ - def __get__(self): - if self.ptr.pts == lib.AV_NOPTS_VALUE: - return None - return self.ptr.pts - - def __set__(self, value): - if value is None: - self.ptr.pts = lib.AV_NOPTS_VALUE - else: - self.ptr.pts = value - - property time: - """ - The presentation time in seconds for this frame. - - This is the time at which the frame should be shown to the user. - - :type: float - """ - def __get__(self): - if self.ptr.pts == lib.AV_NOPTS_VALUE: - return None - else: - return float(self.ptr.pts) * self._time_base.num / self._time_base.den - - property time_base: - """ - The unit of time (in fractional seconds) in which timestamps are expressed. - - :type: fractions.Fraction - """ - def __get__(self): - if self._time_base.num: - return avrational_to_fraction(&self._time_base) - - def __set__(self, value): - to_avrational(value, &self._time_base) - - property is_corrupt: - """ - Is this frame corrupt? - - :type: bool - """ - def __get__(self): return self.ptr.decode_error_flags != 0 or bool(self.ptr.flags & lib.AV_FRAME_FLAG_CORRUPT) - - @property - def side_data(self): - if self._side_data is None: - self._side_data = SideDataContainer(self) - return self._side_data diff --git a/av/index.pxd b/av/index.pxd new file mode 100644 index 000000000..d7098c2f0 --- /dev/null +++ b/av/index.pxd @@ -0,0 +1,12 @@ +cimport libav as lib + + +cdef class IndexEntry: + cdef const lib.AVIndexEntry *ptr + cdef _init(self, const lib.AVIndexEntry *ptr) + +cdef class IndexEntries: + cdef lib.AVStream *stream_ptr + cdef _init(self, lib.AVStream *ptr) + +cdef IndexEntries wrap_index_entries(lib.AVStream *ptr) diff --git a/av/index.py b/av/index.py new file mode 100644 index 000000000..b652bfdc1 --- /dev/null +++ b/av/index.py @@ -0,0 +1,149 @@ +import cython +import cython.cimports.libav as lib +from cython.cimports.libc.stdint import int64_t + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_index_entry(ptr: cython.pointer[cython.const[lib.AVIndexEntry]]) -> IndexEntry: + obj: IndexEntry = IndexEntry(_cinit_bypass_sentinel) + obj._init(ptr) + return obj + + +@cython.final +@cython.cclass +class IndexEntry: + """A single entry from a stream's index. + + This is a thin wrapper around FFmpeg's ``AVIndexEntry``. + + The exact meaning of the fields depends on the container/demuxer. + """ + + def __cinit__(self, sentinel): + if sentinel is not _cinit_bypass_sentinel: + raise RuntimeError("cannot manually instantiate IndexEntry") + + @cython.cfunc + def _init(self, ptr: cython.pointer[cython.const[lib.AVIndexEntry]]): + self.ptr = ptr + + def __repr__(self): + return ( + f"" + ) + + @property + def pos(self): + return self.ptr.pos + + @property + def timestamp(self): + return self.ptr.timestamp + + @property + def flags(self): + return self.ptr.flags + + @property + def is_keyframe(self): + return bool(self.ptr.flags & lib.AVINDEX_KEYFRAME) + + @property + def is_discard(self): + return bool(self.ptr.flags & lib.AVINDEX_DISCARD_FRAME) + + @property + def size(self): + return self.ptr.size + + @property + def min_distance(self): + return self.ptr.min_distance + + +@cython.cfunc +def wrap_index_entries(ptr: cython.pointer[lib.AVStream]) -> IndexEntries: + obj: IndexEntries = IndexEntries(_cinit_bypass_sentinel) + obj._init(ptr) + return obj + + +@cython.final +@cython.cclass +class IndexEntries: + """A sequence-like view of FFmpeg's per-stream index entries. + + Exposed as :attr:`~av.stream.Stream.index_entries`. + + The index is provided by the demuxer and may be empty or incomplete depending + on the container format. This is useful for fast multi-seek loops (e.g., decoding + at a lower-than-native framerate). + """ + + def __cinit__(self, sentinel): + if sentinel is _cinit_bypass_sentinel: + return + raise RuntimeError("cannot manually instantiate IndexEntries") + + @cython.cfunc + def _init(self, ptr: cython.pointer[lib.AVStream]): + self.stream_ptr = ptr + + def __repr__(self): + return f"" + + def __len__(self) -> int: + with cython.nogil: + return lib.avformat_index_get_entries_count(self.stream_ptr) + + def __iter__(self): + for i in range(len(self)): + yield self[i] + + def __getitem__(self, index): + if isinstance(index, int): + n = len(self) + if index < 0: + index += n + if index < 0 or index >= n: + raise IndexError(f"Index entries {index} out of bounds for size {n}") + + c_idx: cython.int = index + entry: cython.pointer[cython.const[lib.AVIndexEntry]] + with cython.nogil: + entry = lib.avformat_index_get_entry(self.stream_ptr, c_idx) + if entry == cython.NULL: + raise IndexError("index entry not found") + + return wrap_index_entry(entry) + elif isinstance(index, slice): + start, stop, step = index.indices(len(self)) + return [self[i] for i in range(start, stop, step)] + else: + raise TypeError("Index must be an integer or a slice") + + def search_timestamp( + self, timestamp, *, backward: bool = True, any_frame: bool = False + ): + """Search the underlying index for ``timestamp``. + + This wraps FFmpeg's ``av_index_search_timestamp``. + + Returns an index into this object, or ``-1`` if no match is found. + """ + c_timestamp: int64_t = timestamp + flags: cython.int = 0 + + if backward: + flags |= lib.AVSEEK_FLAG_BACKWARD + if any_frame: + flags |= lib.AVSEEK_FLAG_ANY + + with cython.nogil: + idx = lib.av_index_search_timestamp(self.stream_ptr, c_timestamp, flags) + + return idx diff --git a/av/index.pyi b/av/index.pyi new file mode 100644 index 000000000..1ccec7a66 --- /dev/null +++ b/av/index.pyi @@ -0,0 +1,22 @@ +from collections.abc import Iterator +from typing import overload + +class IndexEntry: + pos: int + timestamp: int + flags: int + is_keyframe: bool + is_discard: bool + size: int + min_distance: int + +class IndexEntries: + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[IndexEntry]: ... + @overload + def __getitem__(self, index: int) -> IndexEntry: ... + @overload + def __getitem__(self, index: slice) -> list[IndexEntry]: ... + def search_timestamp( + self, timestamp, *, backward: bool = True, any_frame: bool = False + ) -> int: ... diff --git a/av/logging.pxd b/av/logging.pxd index a886a0f20..b5a99be02 100644 --- a/av/logging.pxd +++ b/av/logging.pxd @@ -1,2 +1,12 @@ +cimport libav as lib + + +cdef extern from "Python.h" nogil: + void PyErr_PrintEx(int set_sys_last_vars) + int Py_IsInitialized() + void PyErr_Display(object, object, object) + +cdef extern from "stdio.h" nogil: + cdef int vsnprintf(char *output, int n, const char *format, lib.va_list args) cpdef get_last_error() diff --git a/av/logging.py b/av/logging.py new file mode 100644 index 000000000..1d484b679 --- /dev/null +++ b/av/logging.py @@ -0,0 +1,382 @@ +# type: ignore +""" +FFmpeg has a logging system that it uses extensively. It's very noisy, so PyAV turns it +off by default. This unfortunately has the effect of making raised errors have less +detailed messages. It's therefore recommended to use VERBOSE when developing. + +.. _enable_logging: + +Enabling Logging +~~~~~~~~~~~~~~~~~ + +You can hook into the logging system with Python by setting the log level:: + + import av + + av.logging.set_level(av.logging.VERBOSE) + + +PyAV hooks into that system to translate FFmpeg logs into Python's +`logging system `_. + +If you are not already using Python's logging system, you can initialize it +quickly with:: + + import logging + logging.basicConfig() + + +Note that handling logs with Python sometimes doesn't play nicely with multi-threaded workflows. +An alternative is :func:`restore_default_callback`. + +This restores FFmpeg's default logging system, which prints to the terminal. +Like with setting the log level to ``None``, this may also result in raised errors +having less detailed messages. + + +API Reference +~~~~~~~~~~~~~ + +""" + +import logging +import sys +from threading import Lock, get_ident + +import cython +from cython.cimports.libc.stdio import fprintf, stderr +from cython.cimports.libc.stdlib import free, malloc + +# Library levels. +PANIC = lib.AV_LOG_PANIC # 0 +FATAL = lib.AV_LOG_FATAL # 8 +ERROR = lib.AV_LOG_ERROR +WARNING = lib.AV_LOG_WARNING +INFO = lib.AV_LOG_INFO +VERBOSE = lib.AV_LOG_VERBOSE +DEBUG = lib.AV_LOG_DEBUG +TRACE = lib.AV_LOG_TRACE + +# Mimicking stdlib. +CRITICAL = FATAL + + +@cython.ccall +def adapt_level(level: cython.int): + """Convert a library log level to a Python log level.""" + if level <= lib.AV_LOG_FATAL: # Includes PANIC + return 50 # logging.CRITICAL + elif level <= lib.AV_LOG_ERROR: + return 40 # logging.ERROR + elif level <= lib.AV_LOG_WARNING: + return 30 # logging.WARNING + elif level <= lib.AV_LOG_INFO: + return 20 # logging.INFO + elif level <= lib.AV_LOG_VERBOSE: + return 10 # logging.DEBUG + elif level <= lib.AV_LOG_DEBUG: + return 5 # Lower than any logging constant. + else: # lib.AV_LOG_TRACE + return 1 + + +level_threshold = cython.declare(object, None) +c_level_threshold = cython.declare(cython.int, lib.AV_LOG_QUIET) + +# ... but lets limit ourselves to WARNING (assuming nobody already did this). +if "libav" not in logging.Logger.manager.loggerDict: + logging.getLogger("libav").setLevel(logging.WARNING) + + +def get_level(): + """Returns the current log level. See :func:`set_level`.""" + return level_threshold + + +def set_level(level): + """set_level(level) + + Sets PyAV's log level. It can be set to constants available in this + module: ``PANIC``, ``FATAL``, ``ERROR``, ``WARNING``, ``INFO``, + ``VERBOSE``, ``DEBUG``, or ``None`` (the default). + + PyAV defaults to totally ignoring all ffmpeg logs. This has the side effect of + making certain Exceptions have no messages. It's therefore recommended to use: + + av.logging.set_level(av.logging.VERBOSE) + + When developing your application. + """ + global level_threshold + global c_level_threshold + + if level is None: + level_threshold = level + lib.av_log_set_callback(nolog_callback) + elif type(level) is int: + level_threshold = level + c_level_threshold = level + lib.av_log_set_callback(log_callback) + else: + raise ValueError("level must be: int | None") + + +def set_libav_level(level): + """Set libav's log level. It can be set to constants available in this + module: ``PANIC``, ``FATAL``, ``ERROR``, ``WARNING``, ``INFO``, + ``VERBOSE``, ``DEBUG``. + + When PyAV logging is disabled, setting this will change the level of + the logs printed to the terminal. + """ + lib.av_log_set_level(level) + + +def restore_default_callback(): + """Revert back to FFmpeg's log callback, which prints to the terminal.""" + lib.av_log_set_callback(lib.av_log_default_callback) + + +skip_repeated = cython.declare(cython.bint, True) +skip_lock = cython.declare(object, Lock()) +last_log = cython.declare(object, None) +skip_count = cython.declare(cython.int, 0) + + +def get_skip_repeated(): + """Will identical logs be emitted?""" + return skip_repeated + + +def set_skip_repeated(v): + """Set if identical logs will be emitted""" + global skip_repeated + skip_repeated = bool(v) + + +# For error reporting. +last_error = cython.declare(object, None) +error_count = cython.declare(cython.int, 0) + + +@cython.ccall +def get_last_error(): + """Get the last log that was at least ``ERROR``.""" + if error_count: + with skip_lock: + return error_count, last_error + else: + return 0, None + + +global_captures = cython.declare(list, []) +thread_captures = cython.declare(dict, {}) + + +@cython.final +@cython.cclass +class Capture: + """A context manager for capturing logs. + + :param bool local: Should logs from all threads be captured, or just one + this object is constructed in? + + e.g.:: + + with Capture() as logs: + # Do something. + for log in logs: + print(log.message) + + """ + + logs = cython.declare(list, visibility="readonly") + captures = cython.declare(list, visibility="private") + + def __init__(self, local: cython.bint = True): + self.logs = [] + if local: + self.captures = thread_captures.setdefault(get_ident(), []) + else: + self.captures = global_captures + + def __enter__(self): + self.captures.append(self.logs) + return self.logs + + def __exit__(self, type_, value, traceback): + self.captures.pop() + + +log_context = cython.struct( + class_=cython.pointer[lib.AVClass], + name=cython.p_char, +) + +item_name_func = cython.typedef("const char *(*item_name_func)(void *) noexcept nogil") + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def log_context_name(ptr: cython.p_void) -> cython.p_char: + obj: cython.pointer[log_context] = cython.cast(cython.pointer[log_context], ptr) + return obj.name + + +log_class = cython.declare(lib.AVClass) +log_class.item_name = cython.cast(item_name_func, log_context_name) + + +@cython.ccall +def log(level: cython.int, name: str, message: str): + """Send a log through the library logging system. + + This is mostly for testing. + """ + obj: cython.pointer[log_context] = cython.cast( + cython.pointer[log_context], malloc(cython.sizeof(log_context)) + ) + obj.class_ = cython.address(log_class) + obj.name = name + message_bytes: bytes = message.encode("utf-8") + + lib.av_log( + cython.cast(cython.p_void, obj), + level, + "%s", + cython.cast(cython.p_char, message_bytes), + ) + free(obj) + + +@cython.cfunc +def log_callback_gil( + level: cython.int, c_name: cython.p_const_char, c_message: cython.p_char +): + global error_count + global skip_count + global last_log + global last_error + + name = cython.cast(str, c_name) if c_name is not cython.NULL else "" + message = cython.cast(bytes, c_message).decode("utf8", "backslashreplace") + log = (level, name, message) + + # We have to filter it ourselves, but we will still process it in general so + # it is available to our error handling. + # Note that FFmpeg's levels are backwards from Python's. + is_interesting: cython.bint = level <= level_threshold + + # Skip messages which are identical to the previous. + # TODO: Be smarter about threads. + is_repeated: cython.bint = False + repeat_log: object = None + + with skip_lock: + if is_interesting: + is_repeated = skip_repeated and last_log == log + + if is_repeated: + skip_count += 1 + + elif skip_count: + # Now that we have hit the end of the repeat cycle, tally up how many. + if skip_count == 1: + repeat_log = last_log + else: + repeat_log = ( + last_log[0], + last_log[1], + f"{last_log[2]} (repeated {skip_count} more times)", + ) + skip_count = 0 + + last_log = log + + # Hold onto errors for err_check. + if level == lib.AV_LOG_ERROR: + error_count += 1 + last_error = log + + if repeat_log is not None: + log_callback_emit(repeat_log) + + if is_interesting and not is_repeated: + log_callback_emit(log) + + +@cython.cfunc +def log_callback_emit(log): + lib_level, name, message = log + + captures = thread_captures.get(get_ident()) or global_captures + if captures: + captures[-1].append(log) + return + + py_level = adapt_level(lib_level) + + logger_name = "libav." + name if name else "libav.generic" + logger = logging.getLogger(logger_name) + logger.log(py_level, message.strip()) + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def log_callback( + ptr: cython.p_void, + level: cython.int, + format: cython.p_const_char, + args: lib.va_list, +) -> cython.void: + inited: cython.bint = Py_IsInitialized() + if not inited: + return + if level > c_level_threshold and level != lib.AV_LOG_ERROR: + return + + # Format the message. + message: cython.char[1024] + vsnprintf(message, 1023, format, args) + + # Get the name. + name: cython.p_const_char = cython.NULL + cls: cython.pointer[lib.AVClass] = ( + cython.cast(cython.pointer[cython.pointer[lib.AVClass]], ptr)[0] + if ptr + else cython.NULL + ) + if cls and cls.item_name: + name = cls.item_name(ptr) + + with cython.gil: + try: + log_callback_gil(level, name, message) + except Exception: + fprintf( + stderr, + "av.logging: exception while handling %s[%d]: %s\n", + name, + level, + message, + ) + # For some reason PyErr_PrintEx(0) won't work. + exc, type_, tb = sys.exc_info() + PyErr_Display(exc, type_, tb) + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def nolog_callback( + ptr: cython.p_void, + level: cython.int, + format: cython.p_const_char, + args: lib.va_list, +) -> cython.void: + pass + + +lib.av_log_set_callback(nolog_callback) diff --git a/av/logging.pyi b/av/logging.pyi new file mode 100644 index 000000000..6193ad7b8 --- /dev/null +++ b/av/logging.pyi @@ -0,0 +1,34 @@ +from collections.abc import Callable +from typing import Any + +PANIC: int +FATAL: int +ERROR: int +WARNING: int +INFO: int +VERBOSE: int +DEBUG: int +TRACE: int +CRITICAL: int + +def adapt_level(level: int) -> int: ... +def get_level() -> int | None: ... +def set_level(level: int | None) -> None: ... +def set_libav_level(level: int) -> None: ... +def restore_default_callback() -> None: ... +def get_skip_repeated() -> bool: ... +def set_skip_repeated(v: bool) -> None: ... +def get_last_error() -> tuple[int, tuple[int, str, str] | None]: ... +def log(level: int, name: str, message: str) -> None: ... + +class Capture: + logs: list[tuple[int, str, str]] + + def __init__(self, local: bool = True) -> None: ... + def __enter__(self) -> list[tuple[int, str, str]]: ... + def __exit__( + self, + type_: type | None, + value: Exception | None, + traceback: Callable[..., Any] | None, + ) -> None: ... diff --git a/av/logging.pyx b/av/logging.pyx deleted file mode 100644 index 399e8159e..000000000 --- a/av/logging.pyx +++ /dev/null @@ -1,355 +0,0 @@ -""" -FFmpeg has a logging system that it uses extensively. PyAV hooks into that system -to translate FFmpeg logs into Python's -`logging system `_. - -If you are not already using Python's logging system, you can initialize it -quickly with:: - - import logging - logging.basicConfig() - - -.. _disable_logging: - -Disabling Logging -~~~~~~~~~~~~~~~~~ - -You can disable hooking the logging system with an environment variable:: - - export PYAV_LOGGING=off - -or at runtime with :func:`restore_default_callback`. - -This will leave (or restore) the FFmpeg logging system, which prints to the terminal. -This may also result in raised errors having less detailed messages. - - -API Reference -~~~~~~~~~~~~~ - -""" - -from __future__ import absolute_import - -from libc.stdio cimport fprintf, printf, stderr -from libc.stdlib cimport free, malloc -cimport libav as lib - -from threading import Lock -import logging -import os -import sys - - -try: - from threading import get_ident -except ImportError: - from thread import get_ident - - -cdef bint is_py35 = sys.version_info[:2] >= (3, 5) -cdef str decode_error_handler = 'backslashreplace' if is_py35 else 'replace' - - -# Library levels. -# QUIET = lib.AV_LOG_QUIET # -8; not really a level. -PANIC = lib.AV_LOG_PANIC # 0 -FATAL = lib.AV_LOG_FATAL # 8 -ERROR = lib.AV_LOG_ERROR -WARNING = lib.AV_LOG_WARNING -INFO = lib.AV_LOG_INFO -VERBOSE = lib.AV_LOG_VERBOSE -DEBUG = lib.AV_LOG_DEBUG -TRACE = lib.AV_LOG_TRACE - -# Mimicking stdlib. -CRITICAL = FATAL - - -cpdef adapt_level(int level): - """Convert a library log level to a Python log level.""" - - if level <= lib.AV_LOG_FATAL: # Includes PANIC - return 50 # logging.CRITICAL - elif level <= lib.AV_LOG_ERROR: - return 40 # logging.ERROR - elif level <= lib.AV_LOG_WARNING: - return 30 # logging.WARNING - elif level <= lib.AV_LOG_INFO: - return 20 # logging.INFO - elif level <= lib.AV_LOG_VERBOSE: - return 10 # logging.DEBUG - elif level <= lib.AV_LOG_DEBUG: - return 5 # Lower than any logging constant. - else: # lib.AV_LOG_TRACE - return 1 # ... yeah. - - -# While we start with the level quite low, Python defaults to INFO, and so -# they will not show. The logging system can add significant overhead, so -# be wary of dropping this lower. -cdef int level_threshold = lib.AV_LOG_VERBOSE - -# ... but lets limit ourselves to WARNING (assuming nobody already did this). -if 'libav' not in logging.Logger.manager.loggerDict: - logging.getLogger('libav').setLevel(logging.WARNING) - - -def get_level(): - """Return current FFmpeg logging threshold. See :func:`set_level`.""" - return level_threshold - - -def set_level(int level): - """set_level(level) - - Sets logging threshold when converting from FFmpeg's logging system - to Python's. It is recommended to use the constants available in this - module to set the level: ``PANIC``, ``FATAL``, ``ERROR``, - ``WARNING``, ``INFO``, ``VERBOSE``, and ``DEBUG``. - - While less efficient, it is generally preferable to modify logging - with Python's :mod:`logging`, e.g.:: - - logging.getLogger('libav').setLevel(logging.ERROR) - - PyAV defaults to translating everything except ``AV_LOG_DEBUG``, so this - function is only nessesary to use if you want to see those messages as well. - ``AV_LOG_DEBUG`` will be translated to a level 5 message, which is lower - than any builtin Python logging level, so you must lower that as well:: - - logging.getLogger().setLevel(5) - - """ - global level_threshold - level_threshold = level - - -def restore_default_callback(): - """Revert back to FFmpeg's log callback, which prints to the terminal.""" - lib.av_log_set_callback(lib.av_log_default_callback) - - -cdef bint print_after_shutdown = False - - -def get_print_after_shutdown(): - """Will logging continue to ``stderr`` after Python shutdown?""" - return print_after_shutdown - - -def set_print_after_shutdown(v): - """Set if logging should continue to ``stderr`` after Python shutdown.""" - global print_after_shutdown - print_after_shutdown = bool(v) - - -cdef bint skip_repeated = True -cdef skip_lock = Lock() -cdef object last_log = None -cdef int skip_count = 0 - - -def get_skip_repeated(): - """Will identical logs be emitted?""" - return skip_repeated - - -def set_skip_repeated(v): - """Set if identical logs will be emitted""" - global skip_repeated - skip_repeated = bool(v) - - -# For error reporting. -cdef object last_error = None -cdef int error_count = 0 - -cpdef get_last_error(): - """Get the last log that was at least ``ERROR``.""" - if error_count: - with skip_lock: - return error_count, last_error - else: - return 0, None - - -cdef global_captures = [] -cdef thread_captures = {} - -cdef class Capture(object): - - """A context manager for capturing logs. - - :param bool local: Should logs from all threads be captured, or just one - this object is constructed in? - - e.g.:: - - with Capture() as logs: - # Do something. - for log in logs: - print(log.message) - - """ - - cdef readonly list logs - cdef list captures - - def __init__(self, bint local=True): - - self.logs = [] - - if local: - self.captures = thread_captures.setdefault(get_ident(), []) - else: - self.captures = global_captures - - def __enter__(self): - self.captures.append(self.logs) - return self.logs - - def __exit__(self, type_, value, traceback): - self.captures.pop(-1) - - -cdef struct log_context: - lib.AVClass *class_ - const char *name - -cdef const char *log_context_name(void *ptr) nogil: - cdef log_context *obj = ptr - return obj.name - -cdef lib.AVClass log_class -log_class.item_name = log_context_name - -cpdef log(int level, str name, str message): - """Send a log through the library logging system. - - This is mostly for testing. - - """ - - cdef log_context *obj = malloc(sizeof(log_context)) - obj.class_ = &log_class - obj.name = name - lib.av_log(obj, level, "%s", message) - free(obj) - - -cdef void log_callback(void *ptr, int level, const char *format, lib.va_list args) nogil: - - cdef bint inited = lib.Py_IsInitialized() - if not inited and not print_after_shutdown: - return - - # Format the message. - cdef char message[1024] - lib.vsnprintf(message, 1023, format, args) - - # Get the name. - cdef const char *name = NULL - cdef lib.AVClass *cls = (ptr)[0] if ptr else NULL - if cls and cls.item_name: - # I'm not 100% on this, but this should be static, and so - # it doesn't matter if the AVClass that returned it vanishes or not. - name = cls.item_name(ptr) - - if not inited: - fprintf(stderr, "av.logging (after shutdown): %s[%d]: %s\n", - name, level, message) - return - - with gil: - - try: - log_callback_gil(level, name, message) - - except Exception as e: - fprintf(stderr, "av.logging: exception while handling %s[%d]: %s\n", - name, level, message) - # For some reason lib.PyErr_PrintEx(0) won't work. - exc, type_, tb = sys.exc_info() - lib.PyErr_Display(exc, type_, tb) - - -cdef log_callback_gil(int level, const char *c_name, const char *c_message): - - global error_count - global skip_count - global last_log - global last_error - - name = c_name if c_name is not NULL else '' - message = (c_message).decode('utf8', decode_error_handler) - log = (level, name, message) - - # We have to filter it ourselves, but we will still process it in general so - # it is available to our error handling. - # Note that FFmpeg's levels are backwards from Python's. - cdef bint is_interesting = level <= level_threshold - - # Skip messages which are identical to the previous. - # TODO: Be smarter about threads. - cdef bint is_repeated = False - - cdef object repeat_log = None - - with skip_lock: - - if is_interesting: - - is_repeated = skip_repeated and last_log == log - - if is_repeated: - skip_count += 1 - - elif skip_count: - # Now that we have hit the end of the repeat cycle, tally up how many. - if skip_count == 1: - repeat_log = last_log - else: - repeat_log = ( - last_log[0], - last_log[1], - "%s (repeated %d more times)" % (last_log[2], skip_count) - ) - skip_count = 0 - - last_log = log - - # Hold onto errors for err_check. - if level == lib.AV_LOG_ERROR: - error_count += 1 - last_error = log - - if repeat_log is not None: - log_callback_emit(repeat_log) - - if is_interesting and not is_repeated: - log_callback_emit(log) - - -cdef log_callback_emit(log): - - lib_level, name, message = log - - captures = thread_captures.get(get_ident()) or global_captures - if captures: - captures[-1].append(log) - return - - py_level = adapt_level(lib_level) - - logger_name = 'libav.' + name if name else 'libav.generic' - logger = logging.getLogger(logger_name) - logger.log(py_level, message.strip()) - - -# Start the magic! -# We allow the user to fully disable the logging system as it will not play -# nicely with subinterpreters due to FFmpeg-created threads. -if os.environ.get('PYAV_LOGGING') != 'off': - lib.av_log_set_callback(log_callback) diff --git a/av/opaque.pxd b/av/opaque.pxd new file mode 100644 index 000000000..998e13397 --- /dev/null +++ b/av/opaque.pxd @@ -0,0 +1,11 @@ +cimport libav as lib + + +cdef class OpaqueContainer: + cdef dict _objects + cdef lib.AVBufferRef *add(self, object v) + cdef object get(self, char *name) + cdef object pop(self, char *name) + + +cdef OpaqueContainer opaque_container diff --git a/av/opaque.py b/av/opaque.py new file mode 100644 index 000000000..281033258 --- /dev/null +++ b/av/opaque.py @@ -0,0 +1,58 @@ +# type:ignore +import cython +import cython.cimports.libav as lib +from cython import NULL, sizeof +from cython.cimports.libc.stdint import uint8_t, uintptr_t +from cython.cimports.libc.string import memcpy + +u8ptr = cython.typedef(cython.pointer[uint8_t]) + + +@cython.cfunc +@cython.exceptval(check=False) +@cython.nogil +def key_free(opaque: cython.p_void, data: u8ptr) -> cython.void: + name: cython.p_char = cython.cast(cython.p_char, data) + with cython.gil: + opaque_container.pop(name) + lib.av_free(data) + + +@cython.final +@cython.cclass +class OpaqueContainer: + def __cinit__(self): + self._objects = {} + + @cython.cfunc + def add(self, v: object) -> cython.pointer[lib.AVBufferRef]: + # Use object's memory address as key + key: uintptr_t = cython.cast(uintptr_t, id(v)) + self._objects[key] = v + + data: u8ptr = cython.cast(u8ptr, lib.av_malloc(sizeof(uintptr_t))) + if data == NULL: + raise MemoryError("Failed to allocate memory for key") + + memcpy(data, cython.address(key), sizeof(uintptr_t)) + + # Create the buffer with our free callback + buffer_ref: cython.pointer[lib.AVBufferRef] = lib.av_buffer_create( + data, sizeof(uintptr_t), key_free, NULL, 0 + ) + + if buffer_ref == NULL: + raise MemoryError("Failed to create AVBufferRef") + + return buffer_ref + + def get(self, name) -> object: + key: uintptr_t = cython.cast(cython.pointer[uintptr_t], name)[0] + return self._objects.get(key) + + def pop(self, name) -> object: + key: uintptr_t = cython.cast(cython.pointer[uintptr_t], name)[0] + return self._objects.pop(key, None) + + +opaque_container = OpaqueContainer() diff --git a/av/option.pxd b/av/option.pxd deleted file mode 100644 index e455bff03..000000000 --- a/av/option.pxd +++ /dev/null @@ -1,21 +0,0 @@ -cimport libav as lib - - -cdef class BaseOption(object): - - cdef const lib.AVOption *ptr - - -cdef class Option(BaseOption): - - cdef readonly tuple choices - - -cdef class OptionChoice(BaseOption): - - cdef readonly bint is_default - - -cdef Option wrap_option(tuple choices, const lib.AVOption *ptr) - -cdef OptionChoice wrap_option_choice(const lib.AVOption *ptr, bint is_default) diff --git a/av/option.pyx b/av/option.pyx deleted file mode 100644 index 24f945bf2..000000000 --- a/av/option.pyx +++ /dev/null @@ -1,176 +0,0 @@ -cimport libav as lib - -from av.enum cimport define_enum -from av.utils cimport flag_in_bitfield - - -cdef object _cinit_sentinel = object() - -cdef Option wrap_option(tuple choices, const lib.AVOption *ptr): - if ptr == NULL: - return None - cdef Option obj = Option(_cinit_sentinel) - obj.ptr = ptr - obj.choices = choices - return obj - - -OptionType = define_enum('OptionType', __name__, ( - ('FLAGS', lib.AV_OPT_TYPE_FLAGS), - ('INT', lib.AV_OPT_TYPE_INT), - ('INT64', lib.AV_OPT_TYPE_INT64), - ('DOUBLE', lib.AV_OPT_TYPE_DOUBLE), - ('FLOAT', lib.AV_OPT_TYPE_FLOAT), - ('STRING', lib.AV_OPT_TYPE_STRING), - ('RATIONAL', lib.AV_OPT_TYPE_RATIONAL), - ('BINARY', lib.AV_OPT_TYPE_BINARY), - ('DICT', lib.AV_OPT_TYPE_DICT), - # ('UINT64', lib.AV_OPT_TYPE_UINT64), # Added recently, and not yet used AFAICT. - ('CONST', lib.AV_OPT_TYPE_CONST), - ('IMAGE_SIZE', lib.AV_OPT_TYPE_IMAGE_SIZE), - ('PIXEL_FMT', lib.AV_OPT_TYPE_PIXEL_FMT), - ('SAMPLE_FMT', lib.AV_OPT_TYPE_SAMPLE_FMT), - ('VIDEO_RATE', lib.AV_OPT_TYPE_VIDEO_RATE), - ('DURATION', lib.AV_OPT_TYPE_DURATION), - ('COLOR', lib.AV_OPT_TYPE_COLOR), - ('CHANNEL_LAYOUT', lib.AV_OPT_TYPE_CHANNEL_LAYOUT), - ('BOOL', lib.AV_OPT_TYPE_BOOL), -)) - -cdef tuple _INT_TYPES = ( - lib.AV_OPT_TYPE_FLAGS, - lib.AV_OPT_TYPE_INT, - lib.AV_OPT_TYPE_INT64, - lib.AV_OPT_TYPE_PIXEL_FMT, - lib.AV_OPT_TYPE_SAMPLE_FMT, - lib.AV_OPT_TYPE_DURATION, - lib.AV_OPT_TYPE_CHANNEL_LAYOUT, - lib.AV_OPT_TYPE_BOOL, -) - -OptionFlags = define_enum('OptionFlags', __name__, ( - ('ENCODING_PARAM', lib.AV_OPT_FLAG_ENCODING_PARAM), - ('DECODING_PARAM', lib.AV_OPT_FLAG_DECODING_PARAM), - ('AUDIO_PARAM', lib.AV_OPT_FLAG_AUDIO_PARAM), - ('VIDEO_PARAM', lib.AV_OPT_FLAG_VIDEO_PARAM), - ('SUBTITLE_PARAM', lib.AV_OPT_FLAG_SUBTITLE_PARAM), - ('EXPORT', lib.AV_OPT_FLAG_EXPORT), - ('READONLY', lib.AV_OPT_FLAG_READONLY), - ('FILTERING_PARAM', lib.AV_OPT_FLAG_FILTERING_PARAM), -), is_flags=True) - -cdef class BaseOption(object): - - def __cinit__(self, sentinel): - if sentinel is not _cinit_sentinel: - raise RuntimeError('Cannot construct av.%s' % self.__class__.__name__) - - property name: - def __get__(self): - return self.ptr.name - - property help: - def __get__(self): - return self.ptr.help if self.ptr.help != NULL else '' - - property flags: - def __get__(self): - return self.ptr.flags - - # Option flags - property is_encoding_param: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_ENCODING_PARAM) - property is_decoding_param: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_DECODING_PARAM) - property is_audio_param: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_AUDIO_PARAM) - property is_video_param: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_VIDEO_PARAM) - property is_subtitle_param: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_SUBTITLE_PARAM) - property is_export: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_EXPORT) - property is_readonly: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_READONLY) - property is_filtering_param: - def __get__(self): - return flag_in_bitfield(self.ptr.flags, lib.AV_OPT_FLAG_FILTERING_PARAM) - - -cdef class Option(BaseOption): - - property type: - def __get__(self): - return OptionType._get(self.ptr.type, create=True) - - property offset: - """ - This can be used to find aliases of an option. - Options in a particular descriptor with the same offset are aliases. - """ - def __get__(self): - return self.ptr.offset - - property default: - def __get__(self): - if self.ptr.type in _INT_TYPES: - return self.ptr.default_val.i64 - if self.ptr.type in (lib.AV_OPT_TYPE_DOUBLE, lib.AV_OPT_TYPE_FLOAT, - lib.AV_OPT_TYPE_RATIONAL): - return self.ptr.default_val.dbl - if self.ptr.type in (lib.AV_OPT_TYPE_STRING, lib.AV_OPT_TYPE_BINARY, - lib.AV_OPT_TYPE_IMAGE_SIZE, lib.AV_OPT_TYPE_VIDEO_RATE, - lib.AV_OPT_TYPE_COLOR): - return self.ptr.default_val.str if self.ptr.default_val.str != NULL else '' - - def _norm_range(self, value): - if self.ptr.type in _INT_TYPES: - return int(value) - return value - - property min: - def __get__(self): - return self._norm_range(self.ptr.min) - - property max: - def __get__(self): - return self._norm_range(self.ptr.max) - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.name, - self.type, - self.offset, - id(self), - ) - - -cdef OptionChoice wrap_option_choice(const lib.AVOption *ptr, bint is_default): - if ptr == NULL: - return None - cdef OptionChoice obj = OptionChoice(_cinit_sentinel) - obj.ptr = ptr - obj.is_default = is_default - return obj - - -cdef class OptionChoice(BaseOption): - """ - Represents AV_OPT_TYPE_CONST options which are essentially - choices of non-const option with same unit. - """ - - property value: - def __get__(self): - return self.ptr.default_val.i64 - - def __repr__(self): - return '' % (self.__class__.__name__, self.name, id(self)) diff --git a/av/packet.pxd b/av/packet.pxd index 317443fde..1546a1315 100644 --- a/av/packet.pxd +++ b/av/packet.pxd @@ -1,21 +1,23 @@ +from cython.cimports.libc.stdint import uint8_t + cimport libav as lib -from av.buffer cimport Buffer -from av.bytesource cimport ByteSource +from av.buffer cimport Buffer, ByteSource from av.stream cimport Stream -cdef class Packet(Buffer): +cdef class PacketSideData(Buffer): + cdef uint8_t *data + cdef size_t size + cdef lib.AVPacketSideDataType dtype - cdef lib.AVPacket struct + cdef size_t _buffer_size(self) + cdef void* _buffer_ptr(self) + cdef bint _buffer_writable(self) +cdef class Packet(Buffer): + cdef lib.AVPacket* ptr cdef Stream _stream - - # We track our own time. - cdef lib.AVRational _time_base cdef _rebase_time(self, lib.AVRational) - - # Hold onto the original reference. - cdef ByteSource source cdef size_t _buffer_size(self) cdef void* _buffer_ptr(self) diff --git a/av/packet.py b/av/packet.py new file mode 100644 index 000000000..3fb7d4f4c --- /dev/null +++ b/av/packet.py @@ -0,0 +1,517 @@ +from collections.abc import Iterator +from typing import Literal, get_args + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.buffer import Buffer, ByteSource, bytesource +from cython.cimports.av.error import err_check +from cython.cimports.av.opaque import opaque_container +from cython.cimports.av.utils import avrational_to_fraction, to_avrational +from cython.cimports.cpython.ref import Py_DECREF, Py_INCREF +from cython.cimports.libc.stdint import uint8_t +from cython.cimports.libc.string import memcpy + +from av.rational import AVRational + +# Check https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/packet.h#L41 +# for new additions in the future ffmpeg releases +# Note: the order must follow that of the AVPacketSideDataType enum def +PktSideDataT = Literal[ + "palette", + "new_extradata", + "param_change", + "h263_mb_info", + "replay_gain", + "display_matrix", + "stereo_3d", + "audio_service_type", + "quality_stats", + "fallback_track", + "cpb_properties", + "skip_samples", + "jp_dual_mono", + "strings_metadata", + "subtitle_position", + "matroska_block_additional", + "webvtt_identifier", + "webvtt_settings", + "metadata_update", + "mpegts_stream_id", + "mastering_display_metadata", + "spherical", + "content_light_level", + "a53_cc", + "encryption_init_info", + "encryption_info", + "afd", + "prft", + "icc_profile", + "dovi_conf", + "s12m_timecode", + "dynamic_hdr10_plus", + "iamf_mix_gain_param", + "iamf_info_param", + "iamf_recon_gain_info_param", + "ambient_viewing_environment", + "frame_cropping", + "lcevc", + "3d_reference_displays", + "rtcp_sr", +] + + +def packet_sidedata_type_to_literal(dtype: lib.AVPacketSideDataType) -> PktSideDataT: + return get_args(PktSideDataT)[cython.cast(int, dtype)] + + +def packet_sidedata_type_from_literal(dtype: PktSideDataT) -> lib.AVPacketSideDataType: + return get_args(PktSideDataT).index(dtype) + + +@cython.final +@cython.cclass +class PacketSideData(Buffer): + @staticmethod + def from_packet(packet: Packet, data_type: PktSideDataT) -> PacketSideData: + """create new PacketSideData by copying an existing packet's side data + + :param packet: Source packet + :type packet: :class:`~av.packet.Packet` + :param data_type: side data type + :return: newly created copy of the side data if the side data of the + requested type is found in the packet, else an empty object + :rtype: :class:`~av.packet.PacketSideData` + """ + + dtype = packet_sidedata_type_from_literal(data_type) + return _packet_sidedata_from_packet(packet.ptr, dtype) + + def __cinit__(self, dtype: lib.AVPacketSideDataType, size: cython.size_t): + self.dtype = dtype + with cython.nogil: + if size: + self.data = cython.cast(cython.p_uchar, lib.av_malloc(size)) + if self.data == cython.NULL: + raise MemoryError("Failed to allocate memory") + else: + self.data = cython.NULL + self.size = size + + def __dealloc__(self): + with cython.nogil: + lib.av_freep(cython.address(self.data)) + + def to_packet(self, packet: Packet, move: cython.bint = False): + """copy or move side data to the specified packet + + :param packet: Target packet + :type packet: :class:`~av.packet.Packet` + :param move: True to move the data from this object to the packet, + defaults to False. + :type move: bool + """ + if self.size == 0: + # nothing to add, should clear existing side_data in packet? + return + + data = self.data + + with cython.nogil: + if not move: + data = cython.cast(cython.p_uchar, lib.av_malloc(self.size)) + if data == cython.NULL: + raise MemoryError("Failed to allocate memory") + memcpy(data, self.data, self.size) + + res = lib.av_packet_add_side_data(packet.ptr, self.dtype, data, self.size) + err_check(res) + + if move: + self.data = cython.NULL + self.size = 0 + + @property + def data_type(self) -> str: + """ + The type of this packet side data. + + :type: str + """ + return packet_sidedata_type_to_literal(self.dtype) + + @property + def data_desc(self) -> str: + """ + The description of this packet side data type. + + :type: str + """ + + return lib.av_packet_side_data_name(self.dtype) + + @property + def data_size(self) -> int: + """ + The size in bytes of this packet side data. + + :type: int + """ + return self.size + + # Buffer protocol implementation + @cython.cfunc + def _buffer_size(self) -> cython.size_t: + return self.size + + @cython.cfunc + def _buffer_ptr(self) -> cython.p_void: + return self.data + + @cython.cfunc + def _buffer_writable(self) -> cython.bint: + return True + + def __bool__(self) -> bool: + """ + True if this object holds side data. + + :type: bool + """ + return self.data != cython.NULL + + +@cython.cfunc +def _packet_sidedata_from_packet( + packet: cython.pointer[lib.AVPacket], dtype: lib.AVPacketSideDataType +) -> PacketSideData: + with cython.nogil: + c_ptr = lib.av_packet_side_data_get( + packet.side_data, packet.side_data_elems, dtype + ) + found: cython.bint = c_ptr != cython.NULL + + sdata = PacketSideData(dtype, c_ptr.size if found else 0) + + with cython.nogil: + if found: + memcpy(sdata.data, c_ptr.data, c_ptr.size) + + return sdata + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def _python_free( + opaque: cython.p_void, + data: cython.pointer[uint8_t], +) -> cython.void: + if opaque != cython.NULL: + with cython.gil: + Py_DECREF(cython.cast(object, opaque)) + + +@cython.final +@cython.cclass +class Packet(Buffer): + """A packet of encoded data within a :class:`~av.format.Stream`. + + This may, or may not include a complete object within a stream. + :meth:`decode` must be called to extract encoded data. + """ + + def __cinit__(self, input=None): + with cython.nogil: + self.ptr = lib.av_packet_alloc() + + def __dealloc__(self): + with cython.nogil: + lib.av_packet_free(cython.address(self.ptr)) + + def __init__(self, input=None): + size: cython.size_t = 0 + source: ByteSource = None + buf: cython.pointer[lib.AVBufferRef] + + if input is None: + return + + if isinstance(input, int): + size = input + if size: + err_check(lib.av_new_packet(self.ptr, cython.cast(cython.int, size))) + else: + source = bytesource(input) + size = source.length + if size: + # Create a buffer that references the source data directly. + Py_INCREF(source) + buf = lib.av_buffer_create( + source.ptr, + size, + _python_free, + cython.cast(cython.p_void, source), + 0, + ) + if buf == cython.NULL: + Py_DECREF(source) + raise MemoryError("Could not allocate AVBufferRef") + self.ptr.buf = buf + self.ptr.data = source.ptr + self.ptr.size = cython.cast(cython.int, size) + + def __repr__(self): + stream = self._stream.index if self._stream else 0 + return ( + f"av.{self.__class__.__name__} of #{stream}, dts={self.dts}," + f" pts={self.pts}; {self.ptr.size} bytes at 0x{id(self):x}>" + ) + + # Buffer protocol. + @cython.cfunc + def _buffer_size(self) -> cython.size_t: + return self.ptr.size + + @cython.cfunc + def _buffer_ptr(self) -> cython.p_void: + return self.ptr.data + + @cython.cfunc + def _rebase_time(self, dst: lib.AVRational): + if not dst.num: + raise ValueError("Cannot rebase to zero time.") + + if not self.ptr.time_base.num: + self.ptr.time_base = dst + return + + if self.ptr.time_base.num == dst.num and self.ptr.time_base.den == dst.den: + return + + lib.av_packet_rescale_ts(self.ptr, self.ptr.time_base, dst) + self.ptr.time_base = dst + + def rescale_ts(self, time_base): + """Rescale the packet timestamps to a new time base. + + This rescales :attr:`pts`, :attr:`dts`, and :attr:`duration`, then updates + :attr:`time_base`. If the current time base is unset, the timestamp values + are unchanged and the new time base is assigned. + + :meth:`~av.container.OutputContainer.mux` already performs this operation + automatically when necessary. + + Wraps :ffmpeg:`av_packet_rescale_ts`. + """ + if not isinstance(time_base, AVRational): + raise TypeError("time_base must be an AVRational") + + dst: lib.AVRational + to_avrational(time_base, cython.address(dst)) + self._rebase_time(dst) + + def decode(self): + """ + Send the packet's data to the decoder and return a list of + :class:`.AudioFrame`, :class:`.VideoFrame` or :class:`.SubtitleSet`. + """ + return self._stream.decode(self) + + @property + def stream_index(self): + return self.ptr.stream_index + + @property + def stream(self): + """ + The :class:`Stream` this packet was demuxed from. + """ + return self._stream + + @stream.setter + def stream(self, stream: Stream): + self._stream = stream + self.ptr.stream_index = stream.ptr.index + + @property + def time_base(self): + """ + The unit of time (in fractional seconds) in which timestamps are expressed. + + :type: fractions.Fraction + """ + return avrational_to_fraction(cython.address(self.ptr.time_base)) + + @time_base.setter + def time_base(self, value): + to_avrational(value, cython.address(self.ptr.time_base)) + + @property + def pts(self): + """ + The presentation timestamp in :attr:`time_base` units for this packet. + + This is the time at which the packet should be shown to the user. + + :type: int | None + """ + if self.ptr.pts != lib.AV_NOPTS_VALUE: + return self.ptr.pts + + @pts.setter + def pts(self, v): + if v is None: + self.ptr.pts = lib.AV_NOPTS_VALUE + else: + self.ptr.pts = v + + @property + def dts(self): + """ + The decoding timestamp in :attr:`time_base` units for this packet. + + :type: int | None + """ + if self.ptr.dts != lib.AV_NOPTS_VALUE: + return self.ptr.dts + + @dts.setter + def dts(self, v): + if v is None: + self.ptr.dts = lib.AV_NOPTS_VALUE + else: + self.ptr.dts = v + + @property + def pos(self): + """ + The byte position of this packet within the :class:`.Stream`. + + Returns `None` if it is not known. + + :type: int | None + """ + if self.ptr.pos != -1: + return self.ptr.pos + + @property + def size(self): + """ + The size in bytes of this packet's data. + + :type: int + """ + return self.ptr.size + + @property + def duration(self): + """ + The duration in :attr:`time_base` units for this packet. + + Returns `None` if it is not known. + + :type: int + """ + if self.ptr.duration != lib.AV_NOPTS_VALUE: + return self.ptr.duration + + @duration.setter + def duration(self, v): + if v is None: + self.ptr.duration = lib.AV_NOPTS_VALUE + else: + self.ptr.duration = v + + @property + def is_keyframe(self): + return bool(self.ptr.flags & lib.AV_PKT_FLAG_KEY) + + @is_keyframe.setter + def is_keyframe(self, v): + if v: + self.ptr.flags |= lib.AV_PKT_FLAG_KEY + else: + self.ptr.flags &= ~(lib.AV_PKT_FLAG_KEY) + + @property + def is_corrupt(self): + return bool(self.ptr.flags & lib.AV_PKT_FLAG_CORRUPT) + + @is_corrupt.setter + def is_corrupt(self, v): + if v: + self.ptr.flags |= lib.AV_PKT_FLAG_CORRUPT + else: + self.ptr.flags &= ~(lib.AV_PKT_FLAG_CORRUPT) + + @property + def is_discard(self): + return bool(self.ptr.flags & lib.AV_PKT_FLAG_DISCARD) + + @property + def is_trusted(self): + return bool(self.ptr.flags & lib.AV_PKT_FLAG_TRUSTED) + + @property + def is_disposable(self): + return bool(self.ptr.flags & lib.AV_PKT_FLAG_DISPOSABLE) + + @property + def opaque(self): + if self.ptr.opaque_ref is not cython.NULL: + return opaque_container.get( + cython.cast(cython.p_char, self.ptr.opaque_ref.data) + ) + + @opaque.setter + def opaque(self, v): + lib.av_buffer_unref(cython.address(self.ptr.opaque_ref)) + + if v is None: + return + self.ptr.opaque_ref = opaque_container.add(v) + + def has_sidedata(self, dtype: str) -> bool: + """True if this packet has the specified side data + + :param dtype: side data type + :type dtype: str + """ + + dtype2 = packet_sidedata_type_from_literal(dtype) + return ( + lib.av_packet_side_data_get( + self.ptr.side_data, self.ptr.side_data_elems, dtype2 + ) + != cython.NULL + ) + + def get_sidedata(self, dtype: str) -> PacketSideData: + """get a copy of the side data + + :param dtype: side data type (:meth:`~av.packet.PacketSideData.sidedata_types` for the full list of options) + :type dtype: str + :return: newly created copy of the side data if the side data of the + requested type is found in the packet, else an empty object + :rtype: :class:`~av.packet.PacketSideData` + """ + return PacketSideData.from_packet(self, dtype) + + def set_sidedata(self, sidedata: PacketSideData, move: cython.bint = False): + """copy or move side data to this packet + + :param sidedata: Source packet side data + :type sidedata: :class:`~av.packet.PacketSideData` + :param move: If True, move the data from `sidedata` object, defaults to False + :type move: bool + """ + sidedata.to_packet(self, move) + + def iter_sidedata(self) -> Iterator[PacketSideData]: + """iterate over side data of this packet. + + :yield: :class:`~av.packet.PacketSideData` object + """ + + for i in range(self.ptr.side_data_elems): + yield _packet_sidedata_from_packet(self.ptr, self.ptr.side_data[i].type) diff --git a/av/packet.pyi b/av/packet.pyi new file mode 100644 index 000000000..1f005bf27 --- /dev/null +++ b/av/packet.pyi @@ -0,0 +1,114 @@ +from collections.abc import Iterator +from fractions import Fraction +from typing import Generic, Literal, TypeVar, overload + +from av.audio.frame import AudioFrame +from av.audio.stream import AudioStream +from av.rational import AVRational +from av.stream import Stream +from av.subtitles.stream import SubtitleStream +from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle +from av.video.frame import VideoFrame +from av.video.stream import VideoStream + +from .buffer import Buffer +from .stream import Stream + +# Sync with definition in 'packet.py' +PktSideDataT = Literal[ + "palette", + "new_extradata", + "param_change", + "h263_mb_info", + "replay_gain", + "display_matrix", + "stereo_3d", + "audio_service_type", + "quality_stats", + "fallback_track", + "cpb_properties", + "skip_samples", + "jp_dual_mono", + "strings_metadata", + "subtitle_position", + "matroska_block_additional", + "webvtt_identifier", + "webvtt_settings", + "metadata_update", + "mpegts_stream_id", + "mastering_display_metadata", + "spherical", + "content_light_level", + "a53_cc", + "encryption_init_info", + "encryption_info", + "afd", + "prft", + "icc_profile", + "dovi_conf", + "s12m_timecode", + "dynamic_hdr10_plus", + "iamf_mix_gain_param", + "iamf_info_param", + "iamf_recon_gain_info_param", + "ambient_viewing_environment", + "frame_cropping", + "lcevc", + "3d_reference_displays", + "rtcp_sr", +] + +class PacketSideData(Buffer): + @staticmethod + def from_packet(packet: Packet[Stream], dtype: PktSideDataT) -> PacketSideData: ... + def to_packet(self, packet: Packet[Stream], move: bool = False): ... + @property + def data_type(self) -> str: ... + @property + def data_desc(self) -> str: ... + @property + def data_size(self) -> int: ... + def __bool__(self) -> bool: ... + +def packet_sidedata_type_to_literal(dtype: int) -> PktSideDataT: ... +def packet_sidedata_type_from_literal(dtype: PktSideDataT) -> int: ... + +# TypeVar for stream types - bound to Stream so it can be any stream type +StreamT = TypeVar("StreamT", bound=Stream) + +class Packet(Buffer, Generic[StreamT]): + stream: StreamT + stream_index: int + time_base: Fraction + pts: int | None + dts: int | None + pos: int | None + size: int + duration: int | None + opaque: object + is_keyframe: bool + is_corrupt: bool + is_discard: bool + is_trusted: bool + is_disposable: bool + + def __init__(self: Packet[Stream], input: int | bytes | None = None) -> None: ... + def rescale_ts(self, time_base: AVRational) -> None: ... + + # Overloads that return the same type as the stream's decode method + @overload + def decode(self: Packet[VideoStream]) -> list[VideoFrame]: ... + @overload + def decode(self: Packet[AudioStream]) -> list[AudioFrame]: ... + @overload + def decode( + self: Packet[SubtitleStream], + ) -> list[AssSubtitle] | list[BitmapSubtitle]: ... + @overload + def decode( + self, + ) -> list[VideoFrame | AudioFrame | AssSubtitle | BitmapSubtitle]: ... + def has_sidedata(self, dtype: PktSideDataT) -> bool: ... + def get_sidedata(self, dtype: PktSideDataT) -> PacketSideData: ... + def set_sidedata(self, sidedata: PacketSideData, move: bool = False) -> None: ... + def iter_sidedata(self) -> Iterator[PacketSideData]: ... diff --git a/av/packet.pyx b/av/packet.pyx deleted file mode 100644 index 2ffbcca62..000000000 --- a/av/packet.pyx +++ /dev/null @@ -1,200 +0,0 @@ -cimport libav as lib - -from av.bytesource cimport bytesource -from av.error cimport err_check -from av.utils cimport avrational_to_fraction, to_avrational - -from av import deprecation - - -cdef class Packet(Buffer): - - """A packet of encoded data within a :class:`~av.format.Stream`. - - This may, or may not include a complete object within a stream. - :meth:`decode` must be called to extract encoded data. - - """ - - def __cinit__(self, input=None): - with nogil: - lib.av_init_packet(&self.struct) - - def __init__(self, input=None): - - cdef size_t size = 0 - cdef ByteSource source = None - - if input is None: - return - - if isinstance(input, (int, long)): - size = input - else: - source = bytesource(input) - size = source.length - - if size: - err_check(lib.av_new_packet(&self.struct, size)) - - if source is not None: - self.update(source) - # TODO: Hold onto the source, and copy its pointer - # instead of its data. - # self.source = source - - def __dealloc__(self): - with nogil: - lib.av_packet_unref(&self.struct) - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self._stream.index if self._stream else 0, - self.dts, - self.pts, - self.struct.size, - id(self), - ) - - # Buffer protocol. - cdef size_t _buffer_size(self): - return self.struct.size - cdef void* _buffer_ptr(self): - return self.struct.data - - cdef _rebase_time(self, lib.AVRational dst): - - if not dst.num: - raise ValueError('Cannot rebase to zero time.') - - if not self._time_base.num: - self._time_base = dst - return - - if self._time_base.num == dst.num and self._time_base.den == dst.den: - return - - lib.av_packet_rescale_ts(&self.struct, self._time_base, dst) - - self._time_base = dst - - def decode(self): - """ - Send the packet's data to the decoder and return a list of - :class:`.AudioFrame`, :class:`.VideoFrame` or :class:`.SubtitleSet`. - """ - return self._stream.decode(self) - - @deprecation.method - def decode_one(self): - """ - Send the packet's data to the decoder and return the first decoded frame. - - Returns ``None`` if there is no frame. - - .. warning:: This method is deprecated, as it silently discards any - other frames which were decoded. - """ - res = self._stream.decode(self) - return res[0] if res else None - - property stream_index: - def __get__(self): - return self.struct.stream_index - - property stream: - """ - The :class:`Stream` this packet was demuxed from. - """ - def __get__(self): - return self._stream - - def __set__(self, Stream stream): - self._stream = stream - self.struct.stream_index = stream._stream.index - - property time_base: - """ - The unit of time (in fractional seconds) in which timestamps are expressed. - - :type: fractions.Fraction - """ - def __get__(self): - return avrational_to_fraction(&self._time_base) - - def __set__(self, value): - to_avrational(value, &self._time_base) - - property pts: - """ - The presentation timestamp in :attr:`time_base` units for this packet. - - This is the time at which the packet should be shown to the user. - - :type: int - """ - def __get__(self): - if self.struct.pts != lib.AV_NOPTS_VALUE: - return self.struct.pts - - def __set__(self, v): - if v is None: - self.struct.pts = lib.AV_NOPTS_VALUE - else: - self.struct.pts = v - - property dts: - """ - The decoding timestamp in :attr:`time_base` units for this packet. - - :type: int - """ - def __get__(self): - if self.struct.dts != lib.AV_NOPTS_VALUE: - return self.struct.dts - - def __set__(self, v): - if v is None: - self.struct.dts = lib.AV_NOPTS_VALUE - else: - self.struct.dts = v - - property pos: - """ - The byte position of this packet within the :class:`.Stream`. - - Returns `None` if it is not known. - - :type: int - """ - def __get__(self): - if self.struct.pos != -1: - return self.struct.pos - - property size: - """ - The size in bytes of this packet's data. - - :type: int - """ - def __get__(self): - return self.struct.size - - property duration: - """ - The duration in :attr:`time_base` units for this packet. - - Returns `None` if it is not known. - - :type: int - """ - def __get__(self): - if self.struct.duration != lib.AV_NOPTS_VALUE: - return self.struct.duration - - property is_keyframe: - def __get__(self): return bool(self.struct.flags & lib.AV_PKT_FLAG_KEY) - - property is_corrupt: - def __get__(self): return bool(self.struct.flags & lib.AV_PKT_FLAG_CORRUPT) diff --git a/av/plane.pxd b/av/plane.pxd index df3847d7b..2066bfea8 100644 --- a/av/plane.pxd +++ b/av/plane.pxd @@ -3,9 +3,7 @@ from av.frame cimport Frame cdef class Plane(Buffer): - cdef Frame frame cdef int index - cdef size_t _buffer_size(self) cdef void* _buffer_ptr(self) diff --git a/av/plane.py b/av/plane.py new file mode 100644 index 000000000..f84a99c3d --- /dev/null +++ b/av/plane.py @@ -0,0 +1,24 @@ +import cython + + +@cython.cclass +class Plane(Buffer): + """ + Base class for audio and video planes. + + See also :class:`~av.audio.plane.AudioPlane` and :class:`~av.video.plane.VideoPlane`. + """ + + def __cinit__(self, frame: Frame, index: cython.int): + self.frame = frame + self.index = index + + def __repr__(self): + return ( + f"" + ) + + @cython.cfunc + def _buffer_ptr(self) -> cython.p_void: + return self.frame.ptr.extended_data[self.index] diff --git a/av/plane.pyi b/av/plane.pyi new file mode 100644 index 000000000..99594d11a --- /dev/null +++ b/av/plane.pyi @@ -0,0 +1,8 @@ +from .buffer import Buffer +from .frame import Frame + +class Plane(Buffer): + frame: Frame + index: int + + def __init__(self, frame: Frame, index: int) -> None: ... diff --git a/av/plane.pyx b/av/plane.pyx deleted file mode 100644 index e3ec291a6..000000000 --- a/av/plane.pyx +++ /dev/null @@ -1,22 +0,0 @@ - -cdef class Plane(Buffer): - """ - Base class for audio and video planes. - - See also :class:`~av.audio.plane.AudioPlane` and :class:`~av.video.plane.VideoPlane`. - """ - - def __cinit__(self, Frame frame, int index): - self.frame = frame - self.index = index - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.buffer_size, - self.buffer_ptr, - id(self), - ) - - cdef void* _buffer_ptr(self): - return self.frame.ptr.extended_data[self.index] diff --git a/av/py.typed b/av/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/av/rational.pxd b/av/rational.pxd new file mode 100644 index 000000000..a416fea49 --- /dev/null +++ b/av/rational.pxd @@ -0,0 +1,11 @@ +cimport libav as lib + + +cdef class AVRational: + cdef readonly int num + cdef readonly int den + + cdef lib.AVRational _q(self) + + +cdef AVRational from_avrational(lib.AVRational q) diff --git a/av/rational.py b/av/rational.py new file mode 100644 index 000000000..87ce7ade9 --- /dev/null +++ b/av/rational.py @@ -0,0 +1,156 @@ +# type: ignore +from fractions import Fraction +from numbers import Rational + +import cython +from cython.cimports import libav as lib + +_INT32_MAX: cython.longlong = 2147483647 + + +@cython.cclass +class AVRational: + """ + An exact rational number stored as two int32s, mirroring FFmpeg's + ``AVRational``. + + Values are always reduced to lowest terms with a positive denominator. + Arithmetic between two :class:`AVRational` uses FFmpeg's ``av_mul_q`` + family: intermediates are computed in int64, then reduced back to int32, + **approximating** the result if it does not fit. Arithmetic with other + numeric types promotes to :class:`fractions.Fraction` (exact). + + Following FFmpeg, a zero denominator is allowed: ``1/0``, ``-1/0`` + (infinities) and ``0/0`` (undefined) exist and, like the unset value + ``AVRational(0, 1)``, are falsy — so ``if rate:`` covers every + not-a-real-value case that used to be ``None``. + + Every PyAV setter that accepts a :class:`fractions.Fraction` (e.g. + ``stream.time_base``, ``codec_context.framerate``) also accepts an + :class:`AVRational`. + """ + + def __init__(self, num=0, den=1): + if den == 1 and isinstance(num, Rational): + num, den = num.numerator, num.denominator + n64: cython.longlong = num + d64: cython.longlong = den + n: cython.int + d: cython.int + if not lib.av_reduce( + cython.address(n), cython.address(d), n64, d64, _INT32_MAX + ): + raise OverflowError(f"{num}/{den} cannot be reduced to fit in int32") + self.num = n + self.den = d + + @cython.cfunc + def _q(self) -> lib.AVRational: + q: lib.AVRational + q.num = self.num + q.den = self.den + return q + + @property + def numerator(self): + return self.num + + @property + def denominator(self): + return self.den + + def __repr__(self): + return f"AVRational({self.num}, {self.den})" + + def __str__(self): + return f"{self.num}/{self.den}" + + def __bool__(self): + return self.num != 0 and self.den != 0 + + def __float__(self): + if self.den == 0: + return float("nan") if self.num == 0 else self.num * float("inf") + return self.num / self.den + + def __hash__(self): + if self.den == 0: + return hash((self.num, 0)) + return hash(Fraction(self.num, self.den)) + + def __reduce__(self): + return (AVRational, (self.num, self.den)) + + def __eq__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return self.num == o.num and self.den == o.den + if self.den == 0: + return False + return Fraction(self.num, self.den) == other + + def __lt__(self, other): + return Fraction(self.num, self.den) < other + + def __le__(self, other): + return Fraction(self.num, self.den) <= other + + def __gt__(self, other): + return Fraction(self.num, self.den) > other + + def __ge__(self, other): + return Fraction(self.num, self.den) >= other + + def __neg__(self): + return AVRational(-self.num, self.den) + + def __mul__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return from_avrational(lib.av_mul_q(self._q(), o._q())) + return Fraction(self.num, self.den) * other + + def __rmul__(self, other): + return other * Fraction(self.num, self.den) + + def __truediv__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + if o.num == 0: + raise ZeroDivisionError(f"{self} / {other}") + return from_avrational(lib.av_div_q(self._q(), o._q())) + return Fraction(self.num, self.den) / other + + def __rtruediv__(self, other): + return other / Fraction(self.num, self.den) + + def __add__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return from_avrational(lib.av_add_q(self._q(), o._q())) + return Fraction(self.num, self.den) + other + + def __radd__(self, other): + return other + Fraction(self.num, self.den) + + def __sub__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return from_avrational(lib.av_sub_q(self._q(), o._q())) + return Fraction(self.num, self.den) - other + + def __rsub__(self, other): + return other - Fraction(self.num, self.den) + + +@cython.cfunc +def from_avrational(q: lib.AVRational) -> AVRational: + obj: AVRational = AVRational.__new__(AVRational) + # FFmpeg does not guarantee reduced form; our invariant requires it. + lib.av_reduce( + cython.address(obj.num), cython.address(obj.den), q.num, q.den, _INT32_MAX + ) + return obj + + +Rational.register(AVRational) diff --git a/av/rational.pyi b/av/rational.pyi new file mode 100644 index 000000000..12716ca04 --- /dev/null +++ b/av/rational.pyi @@ -0,0 +1,29 @@ +from fractions import Fraction +from numbers import Rational +from typing import Any + +class AVRational: + num: int + den: int + def __init__(self, num: int | Rational = 0, den: int = 1) -> None: ... + @property + def numerator(self) -> int: ... + @property + def denominator(self) -> int: ... + def __bool__(self) -> bool: ... + def __float__(self) -> float: ... + def __hash__(self) -> int: ... + def __eq__(self, other: Any) -> bool: ... + def __lt__(self, other: Any) -> bool: ... + def __le__(self, other: Any) -> bool: ... + def __gt__(self, other: Any) -> bool: ... + def __ge__(self, other: Any) -> bool: ... + def __neg__(self) -> AVRational: ... + def __mul__(self, other: Any) -> AVRational | Fraction | float: ... + def __rmul__(self, other: Any) -> Fraction | float: ... + def __truediv__(self, other: Any) -> AVRational | Fraction | float: ... + def __rtruediv__(self, other: Any) -> Fraction | float: ... + def __add__(self, other: Any) -> AVRational | Fraction | float: ... + def __radd__(self, other: Any) -> Fraction | float: ... + def __sub__(self, other: Any) -> AVRational | Fraction | float: ... + def __rsub__(self, other: Any) -> Fraction | float: ... diff --git a/av/sidedata/__init__.pxd b/av/sidedata/__init__.pxd new file mode 100644 index 000000000..e69de29bb diff --git a/av/sidedata/encparams.pxd b/av/sidedata/encparams.pxd new file mode 100644 index 000000000..031b59a42 --- /dev/null +++ b/av/sidedata/encparams.pxd @@ -0,0 +1,11 @@ +cimport libav as lib + +from av.sidedata.sidedata cimport SideData + + +cdef class VideoEncParams(SideData): + pass + + +cdef class VideoBlockParams: + cdef lib.AVVideoBlockParams *ptr diff --git a/av/sidedata/encparams.py b/av/sidedata/encparams.py new file mode 100644 index 000000000..d0e78e8a1 --- /dev/null +++ b/av/sidedata/encparams.py @@ -0,0 +1,186 @@ +from enum import IntEnum + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.sidedata.sidedata import SideData +from cython.cimports.libc.stdint import uint8_t + + +class VideoEncParamsType(IntEnum): + NONE = lib.AV_VIDEO_ENC_PARAMS_NONE + VP9 = lib.AV_VIDEO_ENC_PARAMS_VP9 + H264 = lib.AV_VIDEO_ENC_PARAMS_H264 + MPEG2 = lib.AV_VIDEO_ENC_PARAMS_MPEG2 + + +@cython.final +@cython.cclass +class VideoEncParams(SideData): + def __repr__(self): + return f"" + + @property + def nb_blocks(self): + """ + Number of blocks in the array + May be 0, in which case no per-block information is present. In this case + the values of blocks_offset / block_size are unspecified and should not + be accessed. + """ + return cython.cast( + cython.pointer[lib.AVVideoEncParams], self.ptr.data + ).nb_blocks + + @property + def blocks_offset(self): + """ + Offset in bytes from the beginning of this structure at which the array of blocks starts. + """ + return cython.cast( + cython.pointer[lib.AVVideoEncParams], self.ptr.data + ).blocks_offset + + @property + def block_size(self): + """ + Size of each block in bytes. May not match sizeof(AVVideoBlockParams). + """ + return cython.cast( + cython.pointer[lib.AVVideoEncParams], self.ptr.data + ).block_size + + @property + def codec_type(self): + """ + Type of the parameters (the codec they are used with). + """ + t: lib.AVVideoEncParamsType = cython.cast( + cython.pointer[lib.AVVideoEncParams], self.ptr.data + ).type + return VideoEncParamsType(cython.cast(cython.int, t)) + + @property + def qp(self): + """ + Base quantisation parameter for the frame. The final quantiser for a + given block in a given plane is obtained from this value, possibly + combined with `delta_qp` and the per-block delta in a manner + documented for each type. + """ + return cython.cast(cython.pointer[lib.AVVideoEncParams], self.ptr.data).qp + + @property + def delta_qp(self): + """ + Quantisation parameter offset from the base (per-frame) qp for a given + plane (first index) and AC/DC coefficients (second index). + """ + p: cython.pointer[lib.AVVideoEncParams] = cython.cast( + cython.pointer[lib.AVVideoEncParams], self.ptr.data + ) + return [[p.delta_qp[i][j] for j in range(2)] for i in range(4)] + + def block_params(self, idx): + """ + Get the encoding parameters for a given block + """ + # Validate given index + if idx < 0 or idx >= self.nb_blocks: + raise ValueError("Expected idx in range [0, nb_blocks)") + + return VideoBlockParams(self, idx) + + def qp_map(self): + """ + Convenience method that creates a 2-D map with the quantization parameters per macroblock. + Only for MPEG2 and H264 encoded videos. + """ + import numpy as np + + mb_h: cython.int = (self.frame.ptr.height + 15) // 16 + mb_w: cython.int = (self.frame.ptr.width + 15) // 16 + nb_mb: cython.int = mb_h * mb_w + block_idx, x, y = cython.declare(cython.int) + block: VideoBlockParams + + if self.nb_blocks != nb_mb: + raise RuntimeError( + "Expected frame size to match number of blocks in side data" + ) + + type: lib.AVVideoEncParamsType = cython.cast( + cython.pointer[lib.AVVideoEncParams], self.ptr.data + ).type + if ( + type != lib.AVVideoEncParamsType.AV_VIDEO_ENC_PARAMS_MPEG2 + and type != lib.AVVideoEncParamsType.AV_VIDEO_ENC_PARAMS_H264 + ): + raise ValueError("Expected MPEG2 or H264") + + # Create a 2-D map with the number of macroblocks + map = np.empty((mb_h, mb_w), dtype=np.int32) + + # Fill map with quantization parameter per macroblock + for block_idx in range(nb_mb): + block = VideoBlockParams(self, block_idx) + y = block.src_y // 16 + x = block.src_x // 16 + map[y, x] = self.qp + block.delta_qp + + return map + + +@cython.final +@cython.cclass +class VideoBlockParams: + def __init__(self, video_enc_params: VideoEncParams, idx: cython.int) -> None: + base: cython.pointer[uint8_t] = cython.cast( + cython.pointer[uint8_t], video_enc_params.ptr.data + ) + offset: cython.Py_ssize_t = ( + video_enc_params.blocks_offset + idx * video_enc_params.block_size + ) + self.ptr = cython.cast(cython.pointer[lib.AVVideoBlockParams], base + offset) + + def __repr__(self): + return f"" + + @property + def src_x(self): + """ + Horizontal distance in luma pixels from the top-left corner of the visible frame + to the top-left corner of the block. + Can be negative if top/right padding is present on the coded frame. + """ + return self.ptr.src_x + + @property + def src_y(self): + """ + Vertical distance in luma pixels from the top-left corner of the visible frame + to the top-left corner of the block. + Can be negative if top/right padding is present on the coded frame. + """ + return self.ptr.src_y + + @property + def w(self): + """ + Width of the block in luma pixels + """ + return self.ptr.w + + @property + def h(self): + """ + Height of the block in luma pixels + """ + return self.ptr.h + + @property + def delta_qp(self): + """ + Difference between this block's final quantization parameter and the + corresponding per-frame value. + """ + return self.ptr.delta_qp diff --git a/av/sidedata/encparams.pyi b/av/sidedata/encparams.pyi new file mode 100644 index 000000000..de962ba51 --- /dev/null +++ b/av/sidedata/encparams.pyi @@ -0,0 +1,27 @@ +from enum import IntEnum +from typing import Any, cast + +import numpy as np + +class VideoEncParamsType(IntEnum): + NONE = cast(int, ...) + VP9 = cast(int, ...) + H264 = cast(int, ...) + MPEG2 = cast(int, ...) + +class VideoEncParams: + nb_blocks: int + blocks_offset: int + block_size: int + codec_type: VideoEncParamsType + qp: int + delta_qp: int + def block_params(self, idx: int) -> VideoBlockParams: ... + def qp_map(self) -> np.ndarray[Any, Any]: ... + +class VideoBlockParams: + src_x: int + src_y: int + w: int + h: int + delta_qp: int diff --git a/av/sidedata/motionvectors.pxd b/av/sidedata/motionvectors.pxd index 3b7f88bc1..078648325 100644 --- a/av/sidedata/motionvectors.pxd +++ b/av/sidedata/motionvectors.pxd @@ -4,13 +4,11 @@ from av.frame cimport Frame from av.sidedata.sidedata cimport SideData -cdef class _MotionVectors(SideData): - +cdef class MotionVectors(SideData): cdef dict _vectors - cdef int _len - + cdef Py_ssize_t _len -cdef class MotionVector(object): - cdef _MotionVectors parent +cdef class MotionVector: + cdef MotionVectors parent cdef lib.AVMotionVector *ptr diff --git a/av/sidedata/motionvectors.py b/av/sidedata/motionvectors.py new file mode 100644 index 000000000..369832ba6 --- /dev/null +++ b/av/sidedata/motionvectors.py @@ -0,0 +1,136 @@ +import cython +from cython.cimports import libav as lib +from cython.cimports.av.sidedata.sidedata import SideData +from cython.cimports.libc.stdint import uintptr_t + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.final +@cython.cclass +class MotionVectors(SideData): + def __init__(self, sentinel, frame: Frame, index: cython.int): + SideData.__init__(self, sentinel, frame, index) + self._vectors = {} + self._len = self.ptr.size // cython.sizeof(lib.AVMotionVector) + + def __repr__(self): + return ( + f"" + ) + + def __len__(self): + return self._len + + def __getitem__(self, index: cython.Py_ssize_t): + try: + return self._vectors[index] + except KeyError: + pass + + if index >= self._len: + raise IndexError(index) + + vector = self._vectors[index] = MotionVector( + _cinit_bypass_sentinel, self, index + ) + return vector + + def __iter__(self): + """Iterate over all motion vectors.""" + for i in range(self._len): + yield self[i] + + def to_ndarray(self): + """ + Convert motion vectors to a NumPy structured array. + + Returns a NumPy array with fields corresponding to the AVMotionVector structure. + """ + import numpy as np + + return np.frombuffer( + self, + dtype=np.dtype( + [ + ("source", "int32"), + ("w", "uint8"), + ("h", "uint8"), + ("src_x", "int16"), + ("src_y", "int16"), + ("dst_x", "int16"), + ("dst_y", "int16"), + ("flags", "uint64"), + ("motion_x", "int32"), + ("motion_y", "int32"), + ("motion_scale", "uint16"), + ], + align=True, + ), + ) + + +@cython.final +@cython.cclass +class MotionVector: + """ + Represents a single motion vector from video frame data. + + Motion vectors describe the motion of a block of pixels between frames. + """ + + def __init__(self, sentinel, parent: MotionVectors, index: cython.int): + if sentinel is not _cinit_bypass_sentinel: + raise RuntimeError("cannot manually instantiate MotionVector") + self.parent = parent + base: cython.pointer[lib.AVMotionVector] = cython.cast( + cython.pointer[lib.AVMotionVector], parent.ptr.data + ) + self.ptr = base + index + + def __repr__(self): + return ( + f"" + ) + + @property + def source(self): + return self.ptr.source + + @property + def w(self): + return self.ptr.w + + @property + def h(self): + return self.ptr.h + + @property + def src_x(self): + return self.ptr.src_x + + @property + def src_y(self): + return self.ptr.src_y + + @property + def dst_x(self): + return self.ptr.dst_x + + @property + def dst_y(self): + return self.ptr.dst_y + + @property + def motion_x(self): + return self.ptr.motion_x + + @property + def motion_y(self): + return self.ptr.motion_y + + @property + def motion_scale(self): + return self.ptr.motion_scale diff --git a/av/sidedata/motionvectors.pyi b/av/sidedata/motionvectors.pyi new file mode 100644 index 000000000..6b9971fb4 --- /dev/null +++ b/av/sidedata/motionvectors.pyi @@ -0,0 +1,25 @@ +from typing import Any, overload + +import numpy as np + +from .sidedata import SideData + +class MotionVectors(SideData): + @overload + def __getitem__(self, index: int) -> MotionVector: ... + @overload + def __getitem__(self, index: slice) -> list[MotionVector]: ... + def __len__(self) -> int: ... + def to_ndarray(self) -> np.ndarray[Any, Any]: ... + +class MotionVector: + source: int + w: int + h: int + src_x: int + src_y: int + dst_x: int + dst_y: int + motion_x: int + motion_y: int + motion_scale: int diff --git a/av/sidedata/motionvectors.pyx b/av/sidedata/motionvectors.pyx deleted file mode 100644 index 40b6717e4..000000000 --- a/av/sidedata/motionvectors.pyx +++ /dev/null @@ -1,109 +0,0 @@ -try: - from collections.abc import Sequence -except ImportError: - from collections import Sequence - - -cdef object _cinit_bypass_sentinel = object() - - -# Cython doesn't let us inherit from the abstract Sequence, so we will subclass -# it later. -cdef class _MotionVectors(SideData): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._vectors = {} - self._len = self.ptr.size // sizeof(lib.AVMotionVector) - - def __repr__(self): - return f'self.ptr.data:0x}' - - def __getitem__(self, int index): - - try: - return self._vectors[index] - except KeyError: - pass - - if index >= self._len: - raise IndexError(index) - - vector = self._vectors[index] = MotionVector(_cinit_bypass_sentinel, self, index) - return vector - - def __len__(self): - return self._len - - def to_ndarray(self): - import numpy as np - return np.frombuffer(self, dtype=np.dtype([ - ('source', 'int32'), - ('w', 'uint8'), - ('h', 'uint8'), - ('src_x', 'int16'), - ('src_y', 'int16'), - ('dst_x', 'int16'), - ('dst_y', 'int16'), - ('flags', 'uint64'), - ('motion_x', 'int32'), - ('motion_y', 'int32'), - ('motion_scale', 'uint16'), - ], align=True)) - - -class MotionVectors(_MotionVectors, Sequence): - pass - - -cdef class MotionVector(object): - - def __init__(self, sentinel, _MotionVectors parent, int index): - if sentinel is not _cinit_bypass_sentinel: - raise RuntimeError('cannot manually instatiate MotionVector') - self.parent = parent - cdef lib.AVMotionVector *base = parent.ptr.data - self.ptr = base + index - - def __repr__(self): - return f'' - - @property - def source(self): - return self.ptr.source - - @property - def w(self): - return self.ptr.w - - @property - def h(self): - return self.ptr.h - - @property - def src_x(self): - return self.ptr.src_x - - @property - def src_y(self): - return self.ptr.src_y - - @property - def dst_x(self): - return self.ptr.dst_x - - @property - def dst_y(self): - return self.ptr.dst_y - - @property - def motion_x(self): - return self.ptr.motion_x - - @property - def motion_y(self): - return self.ptr.motion_y - - @property - def motion_scale(self): - return self.ptr.motion_scale diff --git a/av/sidedata/sidedata.pxd b/av/sidedata/sidedata.pxd index f30d8fef7..d97903c16 100644 --- a/av/sidedata/sidedata.pxd +++ b/av/sidedata/sidedata.pxd @@ -1,23 +1,19 @@ - cimport libav as lib from av.buffer cimport Buffer -from av.dictionary cimport _Dictionary, wrap_dictionary +from av.dictionary cimport Dictionary, wrap_dictionary from av.frame cimport Frame cdef class SideData(Buffer): - cdef Frame frame cdef lib.AVFrameSideData *ptr - cdef _Dictionary metadata - + cdef Dictionary metadata cdef SideData wrap_side_data(Frame frame, int index) +cdef int get_display_rotation(Frame frame) -cdef class _SideDataContainer(object): - +cdef class _SideDataContainer: cdef Frame frame - cdef list _by_index cdef dict _by_type diff --git a/av/sidedata/sidedata.py b/av/sidedata/sidedata.py new file mode 100644 index 000000000..d0fa7b0d1 --- /dev/null +++ b/av/sidedata/sidedata.py @@ -0,0 +1,135 @@ +from collections.abc import Mapping +from enum import Enum + +import cython +from cython.cimports.libc.stdint import int32_t, uintptr_t + +from av.sidedata.encparams import VideoEncParams +from av.sidedata.motionvectors import MotionVectors + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +class Type(Enum): + """ + Enum class representing different types of frame data in audio/video processing. + Values are mapped to corresponding AV_FRAME_DATA constants from FFmpeg. + + From: https://github.com/FFmpeg/FFmpeg/blob/master/libavutil/frame.h + """ + + PANSCAN = lib.AV_FRAME_DATA_PANSCAN + A53_CC = lib.AV_FRAME_DATA_A53_CC + STEREO3D = lib.AV_FRAME_DATA_STEREO3D + MATRIXENCODING = lib.AV_FRAME_DATA_MATRIXENCODING + DOWNMIX_INFO = lib.AV_FRAME_DATA_DOWNMIX_INFO + REPLAYGAIN = lib.AV_FRAME_DATA_REPLAYGAIN + DISPLAYMATRIX = lib.AV_FRAME_DATA_DISPLAYMATRIX + AFD = lib.AV_FRAME_DATA_AFD + MOTION_VECTORS = lib.AV_FRAME_DATA_MOTION_VECTORS + SKIP_SAMPLES = lib.AV_FRAME_DATA_SKIP_SAMPLES + AUDIO_SERVICE_TYPE = lib.AV_FRAME_DATA_AUDIO_SERVICE_TYPE + MASTERING_DISPLAY_METADATA = lib.AV_FRAME_DATA_MASTERING_DISPLAY_METADATA + GOP_TIMECODE = lib.AV_FRAME_DATA_GOP_TIMECODE + SPHERICAL = lib.AV_FRAME_DATA_SPHERICAL + CONTENT_LIGHT_LEVEL = lib.AV_FRAME_DATA_CONTENT_LIGHT_LEVEL + ICC_PROFILE = lib.AV_FRAME_DATA_ICC_PROFILE + S12M_TIMECODE = lib.AV_FRAME_DATA_S12M_TIMECODE + DYNAMIC_HDR_PLUS = lib.AV_FRAME_DATA_DYNAMIC_HDR_PLUS + REGIONS_OF_INTEREST = lib.AV_FRAME_DATA_REGIONS_OF_INTEREST + VIDEO_ENC_PARAMS = lib.AV_FRAME_DATA_VIDEO_ENC_PARAMS + SEI_UNREGISTERED = lib.AV_FRAME_DATA_SEI_UNREGISTERED + FILM_GRAIN_PARAMS = lib.AV_FRAME_DATA_FILM_GRAIN_PARAMS + DETECTION_BBOXES = lib.AV_FRAME_DATA_DETECTION_BBOXES + DOVI_RPU_BUFFER = lib.AV_FRAME_DATA_DOVI_RPU_BUFFER + DOVI_METADATA = lib.AV_FRAME_DATA_DOVI_METADATA + DYNAMIC_HDR_VIVID = lib.AV_FRAME_DATA_DYNAMIC_HDR_VIVID + AMBIENT_VIEWING_ENVIRONMENT = lib.AV_FRAME_DATA_AMBIENT_VIEWING_ENVIRONMENT + VIDEO_HINT = lib.AV_FRAME_DATA_VIDEO_HINT + + +@cython.cfunc +def wrap_side_data(frame: Frame, index: cython.int) -> SideData: + if frame.ptr.side_data[index].type == lib.AV_FRAME_DATA_MOTION_VECTORS: + return MotionVectors(_cinit_bypass_sentinel, frame, index) + elif frame.ptr.side_data[index].type == lib.AV_FRAME_DATA_VIDEO_ENC_PARAMS: + return VideoEncParams(_cinit_bypass_sentinel, frame, index) + else: + return SideData(_cinit_bypass_sentinel, frame, index) + + +@cython.cfunc +def get_display_rotation(frame: Frame) -> cython.int: + for i in range(frame.ptr.nb_side_data): + if frame.ptr.side_data[i].type == lib.AV_FRAME_DATA_DISPLAYMATRIX: + return int( + lib.av_display_rotation_get( + cython.cast( + cython.pointer[cython.const[int32_t]], + frame.ptr.side_data[i].data, + ) + ) + ) + return 0 + + +@cython.cclass +class SideData(Buffer): + def __init__(self, sentinel, frame: Frame, index: cython.int): + if sentinel is not _cinit_bypass_sentinel: + raise RuntimeError("cannot manually instatiate SideData") + self.frame = frame + self.ptr = frame.ptr.side_data[index] + self.metadata = wrap_dictionary(self.ptr.metadata) + + @cython.cfunc + def _buffer_size(self) -> cython.size_t: + return self.ptr.size + + @cython.cfunc + def _buffer_ptr(self) -> cython.p_void: + return self.ptr.data + + @cython.cfunc + def _buffer_writable(self) -> cython.bint: + return False + + def __repr__(self): + return f"" + + @property + def type(self): + return Type(self.ptr.type) + + +@cython.cclass +class _SideDataContainer: + def __init__(self, frame: Frame): + self.frame = frame + self._by_index: list = [] + self._by_type: dict = {} + + i: cython.int + data: SideData + + for i in range(self.frame.ptr.nb_side_data): + data = wrap_side_data(frame, i) + self._by_index.append(data) + self._by_type[data.type] = data + + def __len__(self): + return len(self._by_index) + + def __iter__(self): + return iter(self._by_index) + + def __getitem__(self, key): + if isinstance(key, int): + return self._by_index[key] + if isinstance(key, str): + return self._by_type[Type[key]] + return self._by_type[key] + + +class SideDataContainer(_SideDataContainer, Mapping): + pass diff --git a/av/sidedata/sidedata.pyi b/av/sidedata/sidedata.pyi new file mode 100644 index 000000000..3f8ef398e --- /dev/null +++ b/av/sidedata/sidedata.pyi @@ -0,0 +1,52 @@ +from collections.abc import Iterator, Mapping, Sequence +from enum import Enum +from typing import ClassVar, cast, overload + +from av.buffer import Buffer +from av.frame import Frame + +class Type(Enum): + PANSCAN = cast(ClassVar[Type], ...) + A53_CC = cast(ClassVar[Type], ...) + STEREO3D = cast(ClassVar[Type], ...) + MATRIXENCODING = cast(ClassVar[Type], ...) + DOWNMIX_INFO = cast(ClassVar[Type], ...) + REPLAYGAIN = cast(ClassVar[Type], ...) + DISPLAYMATRIX = cast(ClassVar[Type], ...) + AFD = cast(ClassVar[Type], ...) + MOTION_VECTORS = cast(ClassVar[Type], ...) + SKIP_SAMPLES = cast(ClassVar[Type], ...) + AUDIO_SERVICE_TYPE = cast(ClassVar[Type], ...) + MASTERING_DISPLAY_METADATA = cast(ClassVar[Type], ...) + GOP_TIMECODE = cast(ClassVar[Type], ...) + SPHERICAL = cast(ClassVar[Type], ...) + CONTENT_LIGHT_LEVEL = cast(ClassVar[Type], ...) + ICC_PROFILE = cast(ClassVar[Type], ...) + S12M_TIMECODE = cast(ClassVar[Type], ...) + DYNAMIC_HDR_PLUS = cast(ClassVar[Type], ...) + REGIONS_OF_INTEREST = cast(ClassVar[Type], ...) + VIDEO_ENC_PARAMS = cast(ClassVar[Type], ...) + SEI_UNREGISTERED = cast(ClassVar[Type], ...) + FILM_GRAIN_PARAMS = cast(ClassVar[Type], ...) + DETECTION_BBOXES = cast(ClassVar[Type], ...) + DOVI_RPU_BUFFER = cast(ClassVar[Type], ...) + DOVI_METADATA = cast(ClassVar[Type], ...) + DYNAMIC_HDR_VIVID = cast(ClassVar[Type], ...) + AMBIENT_VIEWING_ENVIRONMENT = cast(ClassVar[Type], ...) + VIDEO_HINT = cast(ClassVar[Type], ...) + +class SideData(Buffer): + type: Type + +class SideDataContainer(Mapping): + frame: Frame + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[SideData]: ... + @overload + def __getitem__(self, key: str | int | Type) -> SideData: ... + @overload + def __getitem__(self, key: slice) -> Sequence[SideData]: ... + @overload + def __getitem__( + self, key: str | int | Type | slice + ) -> SideData | Sequence[SideData]: ... diff --git a/av/sidedata/sidedata.pyx b/av/sidedata/sidedata.pyx deleted file mode 100644 index 8f5040657..000000000 --- a/av/sidedata/sidedata.pyx +++ /dev/null @@ -1,106 +0,0 @@ -from av.enum cimport define_enum - -from av.sidedata.motionvectors import MotionVectors - - -try: - from collections.abc import Mapping -except ImportError: - from collections import Mapping - - -cdef object _cinit_bypass_sentinel = object() - - -Type = define_enum('Type', __name__, ( - ('PANSCAN', lib.AV_FRAME_DATA_PANSCAN), - ('A53_CC', lib.AV_FRAME_DATA_A53_CC), - ('STEREO3D', lib.AV_FRAME_DATA_STEREO3D), - ('MATRIXENCODING', lib.AV_FRAME_DATA_MATRIXENCODING), - ('DOWNMIX_INFO', lib.AV_FRAME_DATA_DOWNMIX_INFO), - ('REPLAYGAIN', lib.AV_FRAME_DATA_REPLAYGAIN), - ('DISPLAYMATRIX', lib.AV_FRAME_DATA_DISPLAYMATRIX), - ('AFD', lib.AV_FRAME_DATA_AFD), - ('MOTION_VECTORS', lib.AV_FRAME_DATA_MOTION_VECTORS), - ('SKIP_SAMPLES', lib.AV_FRAME_DATA_SKIP_SAMPLES), - ('AUDIO_SERVICE_TYPE', lib.AV_FRAME_DATA_AUDIO_SERVICE_TYPE), - ('MASTERING_DISPLAY_METADATA', lib.AV_FRAME_DATA_MASTERING_DISPLAY_METADATA), - ('GOP_TIMECODE', lib.AV_FRAME_DATA_GOP_TIMECODE), - ('SPHERICAL', lib.AV_FRAME_DATA_SPHERICAL), - ('CONTENT_LIGHT_LEVEL', lib.AV_FRAME_DATA_CONTENT_LIGHT_LEVEL), - ('ICC_PROFILE', lib.AV_FRAME_DATA_ICC_PROFILE), - - # These are deprecated. See https://github.com/PyAV-Org/PyAV/issues/607 - # ('QP_TABLE_PROPERTIES', lib.AV_FRAME_DATA_QP_TABLE_PROPERTIES), - # ('QP_TABLE_DATA', lib.AV_FRAME_DATA_QP_TABLE_DATA), - -)) - - -cdef SideData wrap_side_data(Frame frame, int index): - - cdef lib.AVFrameSideDataType type_ = frame.ptr.side_data[index].type - if type_ == lib.AV_FRAME_DATA_MOTION_VECTORS: - return MotionVectors(_cinit_bypass_sentinel, frame, index) - else: - return SideData(_cinit_bypass_sentinel, frame, index) - - -cdef class SideData(Buffer): - - def __init__(self, sentinel, Frame frame, int index): - if sentinel is not _cinit_bypass_sentinel: - raise RuntimeError('cannot manually instatiate SideData') - self.frame = frame - self.ptr = frame.ptr.side_data[index] - self.metadata = wrap_dictionary(self.ptr.metadata) - - cdef size_t _buffer_size(self): - return self.ptr.size - - cdef void* _buffer_ptr(self): - return self.ptr.data - - cdef bint _buffer_writable(self): - return False - - def __repr__(self): - return f'self.ptr.data:0x}>' - - @property - def type(self): - return Type.get(self.ptr.type) or self.ptr.type - - -cdef class _SideDataContainer(object): - - def __init__(self, Frame frame): - - self.frame = frame - self._by_index = [] - self._by_type = {} - - cdef int i - cdef SideData data - for i in range(self.frame.ptr.nb_side_data): - data = wrap_side_data(frame, i) - self._by_index.append(data) - self._by_type[data.type] = data - - def __len__(self): - return len(self._by_index) - - def __iter__(self): - return iter(self._by_index) - - def __getitem__(self, key): - - if isinstance(key, int): - return self._by_index[key] - - type_ = Type.get(key) - return self._by_type[type_] - - -class SideDataContainer(_SideDataContainer, Mapping): - pass diff --git a/av/stream.pxd b/av/stream.pxd index fa0ffb2d3..727e420ea 100644 --- a/av/stream.pxd +++ b/av/stream.pxd @@ -1,29 +1,36 @@ -from libc.stdint cimport int64_t cimport libav as lib from av.codec.context cimport CodecContext from av.container.core cimport Container from av.frame cimport Frame +from av.index cimport IndexEntries from av.packet cimport Packet -cdef class Stream(object): +cdef class Stream: + cdef lib.AVStream *ptr # Stream attributes. cdef readonly Container container - - cdef lib.AVStream *_stream cdef readonly dict metadata # CodecContext attributes. - cdef lib.AVCodecContext *_codec_context - cdef const lib.AVCodec *_codec - cdef readonly CodecContext codec_context + cdef readonly IndexEntries index_entries + # Private API. - cdef _init(self, Container, lib.AVStream*) + cdef _init(self, Container, lib.AVStream*, CodecContext) + cdef _assert_has_codec_context(self, int err=*) cdef _finalize_for_output(self) + cdef _set_id(self, value) + + +cdef Stream wrap_stream(Container, lib.AVStream*, CodecContext) + +cdef class DataStream(Stream): + pass -cdef Stream wrap_stream(Container, lib.AVStream*) +cdef class AttachmentStream(Stream): + pass diff --git a/av/stream.py b/av/stream.py new file mode 100644 index 000000000..2d087c8ad --- /dev/null +++ b/av/stream.py @@ -0,0 +1,373 @@ +from enum import IntEnum, IntFlag + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.error import err_check +from cython.cimports.av.index import wrap_index_entries +from cython.cimports.av.utils import ( + avdict_to_dict, + avrational_to_fraction, + dict_to_avdict, + to_avrational, +) + + +class Disposition(IntFlag): + default = 1 << 0 + dub = 1 << 1 + original = 1 << 2 + comment = 1 << 3 + lyrics = 1 << 4 + karaoke = 1 << 5 + forced = 1 << 6 + hearing_impaired = 1 << 7 + visual_impaired = 1 << 8 + clean_effects = 1 << 9 + attached_pic = 1 << 10 + timed_thumbnails = 1 << 11 + non_diegetic = 1 << 12 + captions = 1 << 16 + descriptions = 1 << 17 + metadata = 1 << 18 + dependent = 1 << 19 + still_image = 1 << 20 + multilayer = 1 << 21 + + +class Discard(IntEnum): + none = lib.AVDISCARD_NONE + default = lib.AVDISCARD_DEFAULT + nonref = lib.AVDISCARD_NONREF + bidir = lib.AVDISCARD_BIDIR + nonintra = lib.AVDISCARD_NONINTRA + nonkey = lib.AVDISCARD_NONKEY + all = lib.AVDISCARD_ALL + + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_stream( + container: Container, + c_stream: cython.pointer[lib.AVStream], + codec_context: CodecContext, +) -> Stream: + """Build an av.Stream for an existing AVStream. + + The AVStream MUST be fully constructed and ready for use before this is called. + """ + + # This better be the right one... + assert container.ptr.streams[c_stream.index] == c_stream + + py_stream: Stream + + from av.audio.stream import AudioStream + from av.subtitles.stream import SubtitleStream + from av.video.stream import VideoStream + + if c_stream.codecpar.codec_type == lib.AVMEDIA_TYPE_VIDEO: + py_stream = VideoStream.__new__(VideoStream, _cinit_bypass_sentinel) + elif c_stream.codecpar.codec_type == lib.AVMEDIA_TYPE_AUDIO: + py_stream = AudioStream.__new__(AudioStream, _cinit_bypass_sentinel) + elif c_stream.codecpar.codec_type == lib.AVMEDIA_TYPE_SUBTITLE: + py_stream = SubtitleStream.__new__(SubtitleStream, _cinit_bypass_sentinel) + elif c_stream.codecpar.codec_type == lib.AVMEDIA_TYPE_ATTACHMENT: + py_stream = AttachmentStream.__new__(AttachmentStream, _cinit_bypass_sentinel) + elif c_stream.codecpar.codec_type == lib.AVMEDIA_TYPE_DATA: + py_stream = DataStream.__new__(DataStream, _cinit_bypass_sentinel) + else: + py_stream = Stream.__new__(Stream, _cinit_bypass_sentinel) + + py_stream._init(container, c_stream, codec_context) + return py_stream + + +@cython.cclass +class Stream: + """ + A single stream of audio, video or subtitles within a :class:`.Container`. + + :: + + >>> fh = av.open(video_path) + >>> stream = fh.streams.video[0] + >>> stream + + + This encapsulates a :class:`.CodecContext`, located at :attr:`Stream.codec_context`. + Attribute access is passed through to that context when attributes are missing + on the stream itself. E.g. ``stream.options`` will be the options on the + context. + """ + + def __cinit__(self, name): + if name is _cinit_bypass_sentinel: + return + raise RuntimeError("cannot manually instantiate Stream") + + @cython.cfunc + def _init( + self, + container: Container, + stream: cython.pointer[lib.AVStream], + codec_context: CodecContext, + ): + self.container = container + self.ptr = stream + self.index_entries = wrap_index_entries(self.ptr) + + self.codec_context = codec_context + if self.codec_context: + self.codec_context.stream_index = stream.index + + self.metadata = avdict_to_dict( + stream.metadata, + encoding=self.container.metadata_encoding, + errors=self.container.metadata_errors, + ) + + @cython.cfunc + def _assert_has_codec_context( + self, err: cython.int = lib.AVERROR_DECODER_NOT_FOUND + ): + # Calling into a NULL codec_context is a segfault, not an AttributeError. + if self.codec_context is None: + err_check(err) + + def __repr__(self): + name = getattr(self, "name", None) + return ( + f"'}/" + f"{name or ''} at 0x{id(self):x}>" + ) + + def __setattr__(self, name, value): + if name == "id": + self._set_id(value) + return + if name == "disposition": + self.ptr.disposition = value + return + if name == "discard": + self.ptr.discard = Discard(value).value + return + if name == "time_base": + to_avrational(value, cython.address(self.ptr.time_base)) + return + + # Convenience setter for codec context properties. + if self.codec_context is not None: + setattr(self.codec_context, name, value) + + @cython.cfunc + def _finalize_for_output(self): + dict_to_avdict( + cython.address(self.ptr.metadata), + self.metadata, + encoding=self.container.metadata_encoding, + errors=self.container.metadata_errors, + ) + + if self.codec_context is None: + return + + if not self.ptr.time_base.num: + self.ptr.time_base = self.codec_context.ptr.time_base + + # It prefers if we pass it parameters via this other object. Let's just copy what we want. + err_check( + lib.avcodec_parameters_from_context( + self.ptr.codecpar, self.codec_context.ptr + ) + ) + + @property + def id(self): + """ + The format-specific ID of this stream. + + :type: int + + """ + return self.ptr.id + + @cython.cfunc + def _set_id(self, value): + if value is None: + self.ptr.id = 0 + else: + self.ptr.id = value + + @property + def profiles(self): + """ + List the available profiles for this stream. + + :type: list[str] + """ + if self.codec_context: + return self.codec_context.profiles + else: + return [] + + @property + def profile(self): + """ + The profile of this stream. + + :type: str + """ + if self.codec_context: + return self.codec_context.profile + else: + return None + + @property + def index(self): + """ + The index of this stream in its :class:`.Container`. + + :type: int + """ + return self.ptr.index + + @property + def time_base(self): + """ + The unit of time (in fractional seconds) in which timestamps are expressed. + + :type: fractions.Fraction | None + + """ + return avrational_to_fraction(cython.address(self.ptr.time_base)) + + @property + def start_time(self): + """ + The presentation timestamp in :attr:`time_base` units of the first + frame in this stream. + + :type: int | None + """ + if self.ptr.start_time != lib.AV_NOPTS_VALUE: + return self.ptr.start_time + + @property + def duration(self): + """ + The duration of this stream in :attr:`time_base` units. + + :type: int | None + + """ + if self.ptr.duration != lib.AV_NOPTS_VALUE: + return self.ptr.duration + + @property + def frames(self): + """ + The number of frames this stream contains. + + Returns ``0`` if it is not known. + + :type: int + """ + return self.ptr.nb_frames + + @property + def language(self): + """ + The language of the stream. + + :type: str | None + """ + return self.metadata.get("language") + + @property + def disposition(self): + return Disposition(self.ptr.disposition) + + @property + def discard(self): + """ + Controls which packets of this stream are discarded by the demuxer. + + Set this to e.g. :attr:`Discard.all` on streams you don't need so that + :meth:`.Container.demux` and :meth:`.Container.seek` skip them, avoiding + the cost of synchronizing streams you never read. + + :type: Discard + """ + return Discard(self.ptr.discard) + + @property + def type(self): + """ + The type of the stream. + + :type: Literal["audio", "video", "subtitle", "data", "attachment"] + """ + media_type = lib.av_get_media_type_string(self.ptr.codecpar.codec_type) + return "unknown" if media_type == cython.NULL else media_type + + +@cython.final +@cython.cclass +class DataStream(Stream): + def __repr__(self): + return ( + f"'} at 0x{id(self):x}>" + ) + + @property + def name(self): + desc: cython.pointer[cython.const[lib.AVCodecDescriptor]] = ( + lib.avcodec_descriptor_get(self.ptr.codecpar.codec_id) + ) + if desc == cython.NULL: + return None + return desc.name + + +@cython.final +@cython.cclass +class AttachmentStream(Stream): + """ + An :class:`AttachmentStream` represents a stream of attachment data within a media container. + Typically used to attach font files that are referenced in ASS/SSA Subtitle Streams. + """ + + @property + def name(self): + """ + Returns the file name of the attachment. + + :rtype: str | None + """ + return self.metadata.get("filename") + + @property + def mimetype(self): + """ + Returns the MIME type of the attachment. + + :rtype: str | None + """ + return self.metadata.get("mimetype") + + @property + def data(self): + """Return the raw attachment payload as bytes.""" + extradata: cython.p_uchar = self.ptr.codecpar.extradata + size: cython.Py_ssize_t = self.ptr.codecpar.extradata_size + if extradata == cython.NULL or size <= 0: + return b"" + + payload = bytearray(size) + for i in range(size): + payload[i] = extradata[i] + + return bytes(payload) diff --git a/av/stream.pyi b/av/stream.pyi new file mode 100644 index 000000000..f9148021f --- /dev/null +++ b/av/stream.pyi @@ -0,0 +1,75 @@ +from enum import IntEnum, IntFlag +from fractions import Fraction +from typing import Literal, cast + +from .codec import Codec, CodecContext +from .container import Container +from .index import IndexEntries + +class Disposition(IntFlag): + default = cast(int, ...) + dub = cast(int, ...) + original = cast(int, ...) + comment = cast(int, ...) + lyrics = cast(int, ...) + karaoke = cast(int, ...) + forced = cast(int, ...) + hearing_impaired = cast(int, ...) + visual_impaired = cast(int, ...) + clean_effects = cast(int, ...) + attached_pic = cast(int, ...) + timed_thumbnails = cast(int, ...) + non_diegetic = cast(int, ...) + captions = cast(int, ...) + descriptions = cast(int, ...) + metadata = cast(int, ...) + dependent = cast(int, ...) + still_image = cast(int, ...) + multilayer = cast(int, ...) + +class Discard(IntEnum): + none = cast(int, ...) + default = cast(int, ...) + nonref = cast(int, ...) + bidir = cast(int, ...) + nonintra = cast(int, ...) + nonkey = cast(int, ...) + all = cast(int, ...) + +class Stream: + name: str | None + container: Container + codec: Codec + codec_context: CodecContext + metadata: dict[str, str] + index_entries: IndexEntries + id: int + profiles: list[str] + profile: str | None + index: int + options: dict[str, object] + time_base: Fraction | None + average_rate: Fraction | None + base_rate: Fraction | None + guessed_rate: Fraction | None + start_time: int | None + duration: int | None + disposition: Disposition + discard: Discard + frames: int + language: str | None + type: Literal["video", "audio", "data", "subtitle", "attachment", "unknown"] + + # From context + codec_tag: str + +class DataStream(Stream): + type: Literal["data"] + name: str | None + +class AttachmentStream(Stream): + type: Literal["attachment"] + @property + def mimetype(self) -> str | None: ... + @property + def data(self) -> bytes: ... diff --git a/av/stream.pyx b/av/stream.pyx deleted file mode 100644 index f24912d5f..000000000 --- a/av/stream.pyx +++ /dev/null @@ -1,327 +0,0 @@ -from cpython cimport PyWeakref_NewRef -from libc.stdint cimport int64_t, uint8_t -from libc.string cimport memcpy -cimport libav as lib - -from av.codec.context cimport wrap_codec_context -from av.error cimport err_check -from av.packet cimport Packet -from av.utils cimport ( - avdict_to_dict, - avrational_to_fraction, - dict_to_avdict, - to_avrational -) - -from av import deprecation - - -cdef object _cinit_bypass_sentinel = object() - - -cdef Stream wrap_stream(Container container, lib.AVStream *c_stream): - """Build an av.Stream for an existing AVStream. - - The AVStream MUST be fully constructed and ready for use before this is - called. - - """ - - # This better be the right one... - assert container.ptr.streams[c_stream.index] == c_stream - - cdef Stream py_stream - - if c_stream.codec.codec_type == lib.AVMEDIA_TYPE_VIDEO: - from av.video.stream import VideoStream - py_stream = VideoStream.__new__(VideoStream, _cinit_bypass_sentinel) - elif c_stream.codec.codec_type == lib.AVMEDIA_TYPE_AUDIO: - from av.audio.stream import AudioStream - py_stream = AudioStream.__new__(AudioStream, _cinit_bypass_sentinel) - elif c_stream.codec.codec_type == lib.AVMEDIA_TYPE_SUBTITLE: - from av.subtitles.stream import SubtitleStream - py_stream = SubtitleStream.__new__(SubtitleStream, _cinit_bypass_sentinel) - elif c_stream.codec.codec_type == lib.AVMEDIA_TYPE_DATA: - from av.data.stream import DataStream - py_stream = DataStream.__new__(DataStream, _cinit_bypass_sentinel) - else: - py_stream = Stream.__new__(Stream, _cinit_bypass_sentinel) - - py_stream._init(container, c_stream) - return py_stream - - -cdef class Stream(object): - """ - A single stream of audio, video or subtitles within a :class:`.Container`. - - :: - - >>> fh = av.open(video_path) - >>> stream = fh.streams.video[0] - >>> stream - - - This encapsulates a :class:`.CodecContext`, located at :attr:`Stream.codec_context`. - Attribute access is passed through to that context when attributes are missing - on the stream itself. E.g. ``stream.options`` will be the options on the - context. - """ - - def __cinit__(self, name): - if name is _cinit_bypass_sentinel: - return - raise RuntimeError('cannot manually instatiate Stream') - - cdef _init(self, Container container, lib.AVStream *stream): - - self.container = container - self._stream = stream - - self._codec_context = stream.codec - - self.metadata = avdict_to_dict( - stream.metadata, - encoding=self.container.metadata_encoding, - errors=self.container.metadata_errors, - ) - - # This is an input container! - if self.container.ptr.iformat: - - # Find the codec. - self._codec = lib.avcodec_find_decoder(self._codec_context.codec_id) - if not self._codec: - # TODO: Setup a dummy CodecContext. - self.codec_context = None - return - - # This is an output container! - else: - self._codec = self._codec_context.codec - - self.codec_context = wrap_codec_context(self._codec_context, self._codec, False) - self.codec_context.stream_index = stream.index - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.index, - self.type or '', - self.name or '', - id(self), - ) - - def __getattr__(self, name): - # avoid an infinite loop for unsupported codecs - if self.codec_context is None: - return - - try: - return getattr(self.codec_context, name) - except AttributeError: - try: - return getattr(self.codec_context.codec, name) - except AttributeError: - raise AttributeError(name) - - def __setattr__(self, name, value): - setattr(self.codec_context, name, value) - - cdef _finalize_for_output(self): - - dict_to_avdict( - &self._stream.metadata, self.metadata, - encoding=self.container.metadata_encoding, - errors=self.container.metadata_errors, - ) - - if not self._stream.time_base.num: - self._stream.time_base = self._codec_context.time_base - - # It prefers if we pass it parameters via this other object. - # Lets just copy what we want. - err_check(lib.avcodec_parameters_from_context(self._stream.codecpar, self._stream.codec)) - - def encode(self, frame=None): - """ - Encode an :class:`.AudioFrame` or :class:`.VideoFrame` and return a list - of :class:`.Packet`. - - :return: :class:`list` of :class:`.Packet`. - - .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. - """ - packets = self.codec_context.encode(frame) - cdef Packet packet - for packet in packets: - packet._stream = self - packet.struct.stream_index = self._stream.index - return packets - - def decode(self, packet=None): - """ - Decode a :class:`.Packet` and return a list of :class:`.AudioFrame` - or :class:`.VideoFrame`. - - :return: :class:`list` of :class:`.Frame` subclasses. - - .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.decode`. - """ - return self.codec_context.decode(packet) - - @deprecation.method - def seek(self, offset, **kwargs): - """ - .. seealso:: :meth:`.InputContainer.seek` for documentation on parameters. - The only difference is that ``offset`` will be interpreted in - :attr:`.Stream.time_base` when ``whence == 'time'``. - - .. deprecated:: 6.1.0 - Use :meth:`.InputContainer.seek` with ``stream`` argument instead. - - """ - self.container.seek(offset, stream=self, **kwargs) - - property id: - """ - The format-specific ID of this stream. - - :type: int - - """ - def __get__(self): - return self._stream.id - - def __set__(self, v): - if v is None: - self._stream.id = 0 - else: - self._stream.id = v - - property profile: - """ - The profile of this stream. - - :type: str - """ - def __get__(self): - if self._codec and lib.av_get_profile_name(self._codec, self._codec_context.profile): - return lib.av_get_profile_name(self._codec, self._codec_context.profile) - else: - return None - - property index: - """ - The index of this stream in its :class:`.Container`. - - :type: int - """ - def __get__(self): return self._stream.index - - property time_base: - """ - The unit of time (in fractional seconds) in which timestamps are expressed. - - :type: :class:`~fractions.Fraction` or ``None`` - - """ - def __get__(self): - return avrational_to_fraction(&self._stream.time_base) - - def __set__(self, value): - to_avrational(value, &self._stream.time_base) - - property average_rate: - """ - The average frame rate of this video stream. - - This is calculated when the file is opened by looking at the first - few frames and averaging their rate. - - :type: :class:`~fractions.Fraction` or ``None`` - - - """ - def __get__(self): - return avrational_to_fraction(&self._stream.avg_frame_rate) - - property base_rate: - """ - The base frame rate of this stream. - - This is calculated as the lowest framerate at which the timestamps of - frames can be represented accurately. See :ffmpeg:`AVStream.r_frame_rate` - for more. - - :type: :class:`~fractions.Fraction` or ``None`` - - """ - def __get__(self): - return avrational_to_fraction(&self._stream.r_frame_rate) - - property guessed_rate: - """The guessed frame rate of this stream. - - This is a wrapper around :ffmpeg:`av_guess_frame_rate`, and uses multiple - huristics to decide what is "the" frame rate. - - :type: :class:`~fractions.Fraction` or ``None`` - - """ - def __get__(self): - # The two NULL arguments aren't used in FFmpeg >= 4.0 - cdef lib.AVRational val = lib.av_guess_frame_rate(NULL, self._stream, NULL) - return avrational_to_fraction(&val) - - property start_time: - """ - The presentation timestamp in :attr:`time_base` units of the first - frame in this stream. - - :type: :class:`int` or ``None`` - """ - def __get__(self): - if self._stream.start_time != lib.AV_NOPTS_VALUE: - return self._stream.start_time - - property duration: - """ - The duration of this stream in :attr:`time_base` units. - - :type: :class:`int` or ``None`` - - """ - def __get__(self): - if self._stream.duration != lib.AV_NOPTS_VALUE: - return self._stream.duration - - property frames: - """ - The number of frames this stream contains. - - Returns ``0`` if it is not known. - - :type: int - """ - def __get__(self): return self._stream.nb_frames - - property language: - """ - The language of the stream. - - :type: :class:``str`` or ``None`` - """ - def __get__(self): - return self.metadata.get('language') - - @property - def type(self): - """ - The type of the stream. - - Examples: ``'audio'``, ``'video'``, ``'subtitle'``. - - :type: str - """ - return lib.av_get_media_type_string(self._codec_context.codec_type) diff --git a/av/subtitles/__init__.pxd b/av/subtitles/__init__.pxd new file mode 100644 index 000000000..e69de29bb diff --git a/av/subtitles/__init__.pyi b/av/subtitles/__init__.pyi new file mode 100644 index 000000000..c02a53551 --- /dev/null +++ b/av/subtitles/__init__.pyi @@ -0,0 +1,18 @@ +from typing import Literal + +# FFmpeg 8.1 encoders and the codec descriptor aliases that resolve to them. +_SubtitleCodecName = Literal[ + "ass", + "dvb_subtitle", + "dvbsub", + "dvd_subtitle", + "dvdsub", + "mov_text", + "srt", + "ssa", + "subrip", + "text", + "ttml", + "webvtt", + "xsub", +] diff --git a/av/subtitles/codeccontext.pxd b/av/subtitles/codeccontext.pxd index 42141aa4f..fb5748294 100644 --- a/av/subtitles/codeccontext.pxd +++ b/av/subtitles/codeccontext.pxd @@ -1,5 +1,8 @@ from av.codec.context cimport CodecContext +from av.packet cimport Packet cdef class SubtitleCodecContext(CodecContext): - pass + cdef bint subtitle_header_set + cdef _decode(self, Packet packet) + cpdef decode2(self, Packet packet) diff --git a/av/subtitles/codeccontext.py b/av/subtitles/codeccontext.py new file mode 100644 index 000000000..34dc868cf --- /dev/null +++ b/av/subtitles/codeccontext.py @@ -0,0 +1,146 @@ +import cython +from cython.cimports import libav as lib +from cython.cimports.av.buffer import ByteSource, bytesource +from cython.cimports.av.codec.context import CodecContext +from cython.cimports.av.error import err_check +from cython.cimports.av.packet import Packet +from cython.cimports.av.subtitles.subtitle import SubtitleProxy, SubtitleSet +from cython.cimports.cpython.bytes import PyBytes_FromStringAndSize +from cython.cimports.libc.string import memcpy + + +@cython.final +@cython.cclass +class SubtitleCodecContext(CodecContext): + @property + def subtitle_header(self) -> bytes | None: + """Get the subtitle header data (ASS/SSA format for text subtitles).""" + if ( + self.ptr.subtitle_header == cython.NULL + or self.ptr.subtitle_header_size <= 0 + ): + return None + return PyBytes_FromStringAndSize( + cython.cast(cython.p_char, self.ptr.subtitle_header), + self.ptr.subtitle_header_size, + ) + + @subtitle_header.setter + def subtitle_header(self, data: bytes | None) -> None: + """Set the subtitle header data.""" + source: ByteSource + if data is None: + lib.av_freep(cython.address(self.ptr.subtitle_header)) + self.ptr.subtitle_header_size = 0 + else: + source = bytesource(data) + self.ptr.subtitle_header = cython.cast( + cython.p_uchar, + lib.av_realloc( + self.ptr.subtitle_header, + source.length + lib.AV_INPUT_BUFFER_PADDING_SIZE, + ), + ) + if not self.ptr.subtitle_header: + raise MemoryError("Cannot allocate subtitle_header") + memcpy(self.ptr.subtitle_header, source.ptr, source.length) + self.ptr.subtitle_header_size = cython.cast(cython.int, source.length) + self.subtitle_header_set = True + + def __dealloc__(self) -> None: + if self.ptr and self.subtitle_header_set: + lib.av_freep(cython.address(self.ptr.subtitle_header)) + + def encode_subtitle(self, subtitle: SubtitleSet) -> Packet: + """ + Encode a SubtitleSet into a Packet. + + Args: + subtitle: The SubtitleSet to encode + + Returns: + A Packet containing the encoded subtitle data + """ + if not self.codec.ptr: + raise ValueError("Cannot encode with unknown codec") + + self.open(strict=False) + + buf: cython.p_uchar = cython.cast(cython.p_uchar, lib.av_malloc(1024 * 1024)) + if buf == cython.NULL: + raise MemoryError() + + ret: cython.int = lib.avcodec_encode_subtitle( + self.ptr, + buf, + 1024 * 1024, + cython.address(subtitle.proxy.struct), + ) + if ret < 0: + lib.av_free(buf) + err_check(ret, "avcodec_encode_subtitle()") + + packet: Packet = Packet(ret) + memcpy(packet.ptr.data, buf, ret) + lib.av_free(buf) + packet.ptr.pts = subtitle.proxy.struct.pts + packet.ptr.dts = subtitle.proxy.struct.pts + packet.ptr.duration = ( + subtitle.proxy.struct.end_display_time + - subtitle.proxy.struct.start_display_time + ) + + return packet + + @cython.cfunc + def _decode(self, packet: Packet | None): + """Decode a subtitle packet, returning a list of :class:`.Subtitle` objects + if a subtitle was decoded, or an empty list otherwise.""" + if not self.codec.ptr: + raise ValueError("cannot decode unknown codec") + + if packet is None: + raise RuntimeError("packet cannot be None") + + self.open(strict=False) + proxy: SubtitleProxy = SubtitleProxy() + got_frame: cython.int = 0 + + err_check( + lib.avcodec_decode_subtitle2( + self.ptr, + cython.address(proxy.struct), + cython.address(got_frame), + packet.ptr, + ) + ) + + if got_frame: + return list(SubtitleSet(proxy)) + return [] + + @cython.ccall + def decode2(self, packet: Packet): + """ + Returns SubtitleSet if you really need it. + """ + if not self.codec.ptr: + raise ValueError("cannot decode unknown codec") + + self.open(strict=False) + + proxy: SubtitleProxy = SubtitleProxy() + got_frame: cython.int = 0 + + err_check( + lib.avcodec_decode_subtitle2( + self.ptr, + cython.address(proxy.struct), + cython.address(got_frame), + packet.ptr, + ) + ) + + if got_frame: + return SubtitleSet(proxy) + return None diff --git a/av/subtitles/codeccontext.pyi b/av/subtitles/codeccontext.pyi new file mode 100644 index 000000000..7f8a49d09 --- /dev/null +++ b/av/subtitles/codeccontext.pyi @@ -0,0 +1,11 @@ +from typing import Literal + +from av.codec.context import CodecContext +from av.packet import Packet +from av.subtitles.subtitle import SubtitleSet + +class SubtitleCodecContext(CodecContext): + type: Literal["subtitle"] + subtitle_header: bytes | None + def decode2(self, packet: Packet) -> SubtitleSet | None: ... + def encode_subtitle(self, subtitle: SubtitleSet) -> Packet: ... diff --git a/av/subtitles/codeccontext.pyx b/av/subtitles/codeccontext.pyx deleted file mode 100644 index c4d83a256..000000000 --- a/av/subtitles/codeccontext.pyx +++ /dev/null @@ -1,23 +0,0 @@ -cimport libav as lib - -from av.error cimport err_check -from av.frame cimport Frame -from av.packet cimport Packet -from av.subtitles.subtitle cimport SubtitleProxy, SubtitleSet - - -cdef class SubtitleCodecContext(CodecContext): - - cdef _send_packet_and_recv(self, Packet packet): - cdef SubtitleProxy proxy = SubtitleProxy() - - cdef int got_frame = 0 - err_check(lib.avcodec_decode_subtitle2( - self.ptr, - &proxy.struct, - &got_frame, - &packet.struct if packet else NULL)) - if got_frame: - return [SubtitleSet(proxy)] - else: - return [] diff --git a/av/subtitles/stream.pxd b/av/subtitles/stream.pxd index e21dceb23..745032af9 100644 --- a/av/subtitles/stream.pxd +++ b/av/subtitles/stream.pxd @@ -1,5 +1,6 @@ +from av.packet cimport Packet from av.stream cimport Stream cdef class SubtitleStream(Stream): - pass + cpdef decode(self, Packet packet=?) diff --git a/av/subtitles/stream.py b/av/subtitles/stream.py new file mode 100644 index 000000000..a39f8b0a3 --- /dev/null +++ b/av/subtitles/stream.py @@ -0,0 +1,25 @@ +import cython +from cython.cimports.av.packet import Packet +from cython.cimports.av.stream import Stream + + +@cython.final +@cython.cclass +class SubtitleStream(Stream): + def __getattr__(self, name): + return getattr(self.codec_context, name) + + @cython.ccall + def decode(self, packet: Packet | None = None): + """ + Decode a :class:`.Packet` and returns a subtitle object. + + :rtype: list[AssSubtitle] | list[BitmapSubtitle] + + .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. + """ + self._assert_has_codec_context() + if not packet: + packet = Packet() + + return self.codec_context.decode(packet) diff --git a/av/subtitles/stream.pyi b/av/subtitles/stream.pyi new file mode 100644 index 000000000..4c3326f79 --- /dev/null +++ b/av/subtitles/stream.pyi @@ -0,0 +1,12 @@ +from av.packet import Packet +from av.stream import Stream +from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle, SubtitleSet + +from .codeccontext import SubtitleCodecContext + +class SubtitleStream(Stream): + codec_context: SubtitleCodecContext + def decode( + self, packet: Packet | None = None + ) -> list[AssSubtitle] | list[BitmapSubtitle]: ... + def decode2(self, packet: Packet) -> SubtitleSet | None: ... diff --git a/av/subtitles/stream.pyx b/av/subtitles/stream.pyx deleted file mode 100644 index aea5c57e2..000000000 --- a/av/subtitles/stream.pyx +++ /dev/null @@ -1,3 +0,0 @@ - -cdef class SubtitleStream(Stream): - pass diff --git a/av/subtitles/subtitle.pxd b/av/subtitles/subtitle.pxd index ae7bb44b9..4f8556742 100644 --- a/av/subtitles/subtitle.pxd +++ b/av/subtitles/subtitle.pxd @@ -1,22 +1,14 @@ cimport libav as lib -from av.packet cimport Packet - - -cdef class SubtitleProxy(object): +cdef class SubtitleProxy: cdef lib.AVSubtitle struct - -cdef class SubtitleSet(object): - - cdef readonly Packet packet +cdef class SubtitleSet: cdef SubtitleProxy proxy cdef readonly tuple rects - -cdef class Subtitle(object): - +cdef class Subtitle: cdef SubtitleProxy proxy cdef lib.AVSubtitleRect *ptr cdef readonly bytes type @@ -28,11 +20,9 @@ cdef class ASSSubtitle(Subtitle): pass cdef class BitmapSubtitle(Subtitle): - cdef readonly planes -cdef class BitmapSubtitlePlane(object): - +cdef class BitmapSubtitlePlane: cdef readonly BitmapSubtitle subtitle cdef readonly int index cdef readonly long buffer_size diff --git a/av/subtitles/subtitle.py b/av/subtitles/subtitle.py new file mode 100644 index 000000000..2d2b53df6 --- /dev/null +++ b/av/subtitles/subtitle.py @@ -0,0 +1,336 @@ +import cython +from cython.cimports.cpython import PyBuffer_FillInfo, PyBytes_FromString +from cython.cimports.libc.string import memcpy + + +@cython.final +@cython.cclass +class SubtitleProxy: + def __dealloc__(self): + lib.avsubtitle_free(cython.address(self.struct)) + + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.final +@cython.cclass +class SubtitleSet: + """ + A :class:`SubtitleSet` can contain many :class:`Subtitle` objects. + + Wraps :ffmpeg:`AVSubtitle`. + """ + + def __cinit__(self, proxy_or_sentinel=None): + if proxy_or_sentinel is _cinit_bypass_sentinel: + # Creating empty SubtitleSet for encoding + self.proxy = SubtitleProxy() + self.rects = () + elif isinstance(proxy_or_sentinel, SubtitleProxy): + # Creating from decoded subtitle + self.proxy = proxy_or_sentinel + self.rects = tuple( + build_subtitle(self, i) for i in range(self.proxy.struct.num_rects) + ) + else: + raise TypeError( + "SubtitleSet requires a SubtitleProxy or use SubtitleSet.create()" + ) + + @staticmethod + def create( + text: bytes, + start: int, + end: int, + pts: int = 0, + subtitle_format: int = 1, + ) -> SubtitleSet: + """ + Create a SubtitleSet for encoding. + + :param text: The subtitle text in ASS dialogue format + (e.g. ``b"0,0,Default,,0,0,0,,Hello World"``) + :param start: Start display time as offset from pts (typically 0) + :param end: End display time as offset from pts (i.e., duration) + :param pts: Presentation timestamp in stream time_base units + :param subtitle_format: Subtitle format (default 1 for text) + :return: A SubtitleSet ready for encoding + + .. note:: + All timing values should be in stream time_base units. + For MKV (time_base=1/1000), units are milliseconds. + For MP4 (time_base=1/1000000), units are microseconds. + """ + subset: SubtitleSet = SubtitleSet(_cinit_bypass_sentinel) + + subset.proxy.struct.format = subtitle_format + subset.proxy.struct.start_display_time = start + subset.proxy.struct.end_display_time = end + subset.proxy.struct.pts = pts + + subset.proxy.struct.num_rects = 1 + subset.proxy.struct.rects = cython.cast( + cython.pointer[cython.pointer[lib.AVSubtitleRect]], + lib.av_mallocz(cython.sizeof(cython.pointer[lib.AVSubtitleRect])), + ) + if subset.proxy.struct.rects == cython.NULL: + raise MemoryError("Failed to allocate subtitle rects array") + + rect: cython.pointer[lib.AVSubtitleRect] = cython.cast( + cython.pointer[lib.AVSubtitleRect], + lib.av_mallocz(cython.sizeof(lib.AVSubtitleRect)), + ) + if rect == cython.NULL: + lib.av_free(subset.proxy.struct.rects) + subset.proxy.struct.rects = cython.NULL + raise MemoryError("Failed to allocate subtitle rect") + + subset.proxy.struct.rects[0] = rect + + rect.x = 0 + rect.y = 0 + rect.w = 0 + rect.h = 0 + rect.nb_colors = 0 + rect.type = lib.SUBTITLE_ASS + rect.text = cython.NULL + rect.flags = 0 + + text_len: cython.Py_ssize_t = len(text) + rect.ass = cython.cast(cython.p_char, lib.av_malloc(text_len + 1)) + if rect.ass == cython.NULL: + raise MemoryError("Failed to allocate subtitle text") + memcpy(rect.ass, cython.cast(cython.p_char, text), text_len) + rect.ass[text_len] = 0 + + subset.rects = (AssSubtitle(subset, 0),) + + return subset + + def __repr__(self): + return ( + f"<{self.__class__.__module__}.{self.__class__.__name__} at 0x{id(self):x}>" + ) + + @property + def format(self): + return self.proxy.struct.format + + @format.setter + def format(self, value: int): + self.proxy.struct.format = value + + @property + def start_display_time(self): + return self.proxy.struct.start_display_time + + @start_display_time.setter + def start_display_time(self, value: int): + self.proxy.struct.start_display_time = value + + @property + def end_display_time(self): + return self.proxy.struct.end_display_time + + @end_display_time.setter + def end_display_time(self, value: int): + self.proxy.struct.end_display_time = value + + @property + def pts(self): + """Same as packet pts, in av.time_base.""" + return self.proxy.struct.pts + + @pts.setter + def pts(self, value: int): + self.proxy.struct.pts = value + + def __len__(self): + return len(self.rects) + + def __iter__(self): + return iter(self.rects) + + def __getitem__(self, i): + return self.rects[i] + + +@cython.cfunc +def build_subtitle(subtitle: SubtitleSet, index: cython.int) -> Subtitle: + """Build an av.Stream for an existing AVStream. + + The AVStream MUST be fully constructed and ready for use before this is called. + """ + if index < 0 or cython.cast(cython.uint, index) >= subtitle.proxy.struct.num_rects: + raise ValueError("subtitle rect index out of range") + + ptr: cython.pointer[lib.AVSubtitleRect] = subtitle.proxy.struct.rects[index] + + if ptr.type == lib.SUBTITLE_BITMAP: + return BitmapSubtitle(subtitle, index) + if ptr.type == lib.SUBTITLE_ASS or ptr.type == lib.SUBTITLE_TEXT: + return AssSubtitle(subtitle, index) + + raise ValueError(f"unknown subtitle type {ptr.type!r}") + + +@cython.cclass +class Subtitle: + """ + An abstract base class for each concrete type of subtitle. + Wraps :ffmpeg:`AVSubtitleRect` + """ + + def __cinit__(self, subtitle: SubtitleSet, index: cython.int): + if ( + index < 0 + or cython.cast(cython.uint, index) >= subtitle.proxy.struct.num_rects + ): + raise ValueError("subtitle rect index out of range") + self.proxy = subtitle.proxy + self.ptr = self.proxy.struct.rects[index] + + if self.ptr.type == lib.SUBTITLE_NONE: + self.type = b"none" + elif self.ptr.type == lib.SUBTITLE_BITMAP: + self.type = b"bitmap" + elif self.ptr.type == lib.SUBTITLE_TEXT: + self.type = b"text" + elif self.ptr.type == lib.SUBTITLE_ASS: + self.type = b"ass" + else: + raise ValueError(f"unknown subtitle type {self.ptr.type!r}") + + def __repr__(self): + return f"" + + +@cython.final +@cython.cclass +class BitmapSubtitle(Subtitle): + def __cinit__(self, subtitle: SubtitleSet, index: cython.int): + self.planes = tuple( + BitmapSubtitlePlane(self, i) for i in range(4) if self.ptr.linesize[i] + ) + + def __repr__(self): + return ( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"{self.width}x{self.height} at {self.x},{self.y}; at 0x{id(self):x}>" + ) + + @property + def x(self): + return self.ptr.x + + @property + def y(self): + return self.ptr.y + + @property + def width(self): + return self.ptr.w + + @property + def height(self): + return self.ptr.h + + @property + def nb_colors(self): + return self.ptr.nb_colors + + def __len__(self): + return len(self.planes) + + def __iter__(self): + return iter(self.planes) + + def __getitem__(self, i): + return self.planes[i] + + +@cython.final +@cython.cclass +class BitmapSubtitlePlane: + def __cinit__(self, subtitle: BitmapSubtitle, index: cython.int): + if index >= 4: + raise ValueError("BitmapSubtitles have only 4 planes") + if not subtitle.ptr.linesize[index]: + raise ValueError("plane does not exist") + + self.subtitle = subtitle + self.index = index + self.buffer_size = subtitle.ptr.w * subtitle.ptr.h + self._buffer = cython.cast(cython.p_void, subtitle.ptr.data[index]) + + # New-style buffer support. + def __getbuffer__(self, view: cython.pointer[Py_buffer], flags: cython.int): + PyBuffer_FillInfo(view, self, self._buffer, self.buffer_size, 0, flags) + + +@cython.final +@cython.cclass +class AssSubtitle(Subtitle): + """ + Represents an ASS/Text subtitle format, as opposed to a bitmap Subtitle format. + """ + + def __repr__(self): + return f"" + + @property + def ass(self): + """ + Returns the subtitle in the ASS/SSA format. Used by the vast majority of subtitle formats. + """ + if self.ptr.ass is not cython.NULL: + return PyBytes_FromString(self.ptr.ass) + return b"" + + @property + def dialogue(self): + """ + Extract the dialogue from the ass format. Strip comments. + """ + comma_count: cython.short = 0 + i: cython.Py_ssize_t = 0 + state: cython.bint = False + ass_text: bytes = self.ass + char, next_char = cython.declare(cython.uchar) + result: bytearray = bytearray() + text_len: cython.Py_ssize_t = len(ass_text) + + while comma_count < 8 and i < text_len: + if ass_text[i] == b","[0]: + comma_count += 1 + i += 1 + + while i < text_len: + char = ass_text[i] + next_char = 0 if i + 1 >= text_len else ass_text[i + 1] + + if char == b"\\"[0] and next_char == b"N"[0]: + result.append(b"\n"[0]) + i += 2 + continue + + if not state: + if char == b"{"[0] and next_char != b"\\"[0]: + state = True + else: + result.append(char) + elif char == b"}"[0]: + state = False + i += 1 + + return bytes(result) + + @property + def text(self): + """ + Rarely used attribute. You're probably looking for dialogue. + """ + if self.ptr.text is not cython.NULL: + return PyBytes_FromString(self.ptr.text) + return b"" diff --git a/av/subtitles/subtitle.pyi b/av/subtitles/subtitle.pyi new file mode 100644 index 000000000..5f277eba0 --- /dev/null +++ b/av/subtitles/subtitle.pyi @@ -0,0 +1,46 @@ +from collections.abc import Iterator +from typing import Literal + +class SubtitleSet: + format: int + start_display_time: int + end_display_time: int + pts: int + rects: tuple[Subtitle, ...] + + @staticmethod + def create( + text: bytes, + start: int, + end: int, + pts: int = 0, + subtitle_format: int = 1, + ) -> SubtitleSet: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[Subtitle]: ... + def __getitem__(self, i: int) -> Subtitle: ... + +class Subtitle: ... + +class BitmapSubtitle(Subtitle): + type: Literal[b"bitmap"] + x: int + y: int + width: int + height: int + nb_colors: int + planes: tuple[BitmapSubtitlePlane, ...] + +class BitmapSubtitlePlane: + subtitle: BitmapSubtitle + index: int + buffer_size: int + +class AssSubtitle(Subtitle): + type: Literal[b"ass", b"text"] + @property + def ass(self) -> bytes: ... + @property + def dialogue(self) -> bytes: ... + @property + def text(self) -> bytes: ... diff --git a/av/subtitles/subtitle.pyx b/av/subtitles/subtitle.pyx deleted file mode 100644 index 88e5e2a90..000000000 --- a/av/subtitles/subtitle.pyx +++ /dev/null @@ -1,201 +0,0 @@ -from cpython cimport PyBuffer_FillInfo - - -cdef class SubtitleProxy(object): - def __dealloc__(self): - lib.avsubtitle_free(&self.struct) - - -cdef class SubtitleSet(object): - - def __cinit__(self, SubtitleProxy proxy): - self.proxy = proxy - cdef int i - self.rects = tuple(build_subtitle(self, i) for i in range(self.proxy.struct.num_rects)) - - def __repr__(self): - return '<%s.%s at 0x%x>' % ( - self.__class__.__module__, - self.__class__.__name__, - id(self), - ) - - property format: - def __get__(self): return self.proxy.struct.format - property start_display_time: - def __get__(self): return self.proxy.struct.start_display_time - property end_display_time: - def __get__(self): return self.proxy.struct.end_display_time - property pts: - def __get__(self): return self.proxy.struct.pts - - def __len__(self): - return len(self.rects) - - def __iter__(self): - return iter(self.rects) - - def __getitem__(self, i): - return self.rects[i] - - -cdef Subtitle build_subtitle(SubtitleSet subtitle, int index): - """Build an av.Stream for an existing AVStream. - - The AVStream MUST be fully constructed and ready for use before this is - called. - - """ - - if index < 0 or index >= subtitle.proxy.struct.num_rects: - raise ValueError('subtitle rect index out of range') - cdef lib.AVSubtitleRect *ptr = subtitle.proxy.struct.rects[index] - - if ptr.type == lib.SUBTITLE_NONE: - return Subtitle(subtitle, index) - elif ptr.type == lib.SUBTITLE_BITMAP: - return BitmapSubtitle(subtitle, index) - elif ptr.type == lib.SUBTITLE_TEXT: - return TextSubtitle(subtitle, index) - elif ptr.type == lib.SUBTITLE_ASS: - return AssSubtitle(subtitle, index) - else: - raise ValueError('unknown subtitle type %r' % ptr.type) - - -cdef class Subtitle(object): - - def __cinit__(self, SubtitleSet subtitle, int index): - if index < 0 or index >= subtitle.proxy.struct.num_rects: - raise ValueError('subtitle rect index out of range') - self.proxy = subtitle.proxy - self.ptr = self.proxy.struct.rects[index] - - if self.ptr.type == lib.SUBTITLE_NONE: - self.type = b'none' - elif self.ptr.type == lib.SUBTITLE_BITMAP: - self.type = b'bitmap' - elif self.ptr.type == lib.SUBTITLE_TEXT: - self.type = b'text' - elif self.ptr.type == lib.SUBTITLE_ASS: - self.type = b'ass' - else: - raise ValueError('unknown subtitle type %r' % self.ptr.type) - - def __repr__(self): - return '<%s.%s at 0x%x>' % ( - self.__class__.__module__, - self.__class__.__name__, - id(self), - ) - - -cdef class BitmapSubtitle(Subtitle): - - def __cinit__(self, SubtitleSet subtitle, int index): - self.planes = tuple( - BitmapSubtitlePlane(self, i) - for i in range(4) - if self.ptr.linesize[i] - ) - - def __repr__(self): - return '<%s.%s %dx%d at %d,%d; at 0x%x>' % ( - self.__class__.__module__, - self.__class__.__name__, - self.width, - self.height, - self.x, - self.y, - id(self), - ) - - property x: - def __get__(self): return self.ptr.x - property y: - def __get__(self): return self.ptr.y - property width: - def __get__(self): return self.ptr.w - property height: - def __get__(self): return self.ptr.h - property nb_colors: - def __get__(self): return self.ptr.nb_colors - - def __len__(self): - return len(self.planes) - - def __iter__(self): - return iter(self.planes) - - def __getitem__(self, i): - return self.planes[i] - - -cdef class BitmapSubtitlePlane(object): - - def __cinit__(self, BitmapSubtitle subtitle, int index): - - if index >= 4: - raise ValueError('BitmapSubtitles have only 4 planes') - if not subtitle.ptr.linesize[index]: - raise ValueError('plane does not exist') - - self.subtitle = subtitle - self.index = index - self.buffer_size = subtitle.ptr.w * subtitle.ptr.h - self._buffer = subtitle.ptr.data[index] - - # PyBuffer_FromMemory(self.ptr.data[i], self.width * self.height) - - # Legacy buffer support. For `buffer` and PIL. - # See: http://docs.python.org/2/c-api/typeobj.html#PyBufferProcs - - def __getsegcount__(self, Py_ssize_t *len_out): - if len_out != NULL: - len_out[0] = self.buffer_size - return 1 - - def __getreadbuffer__(self, Py_ssize_t index, void **data): - if index: - raise RuntimeError("accessing non-existent buffer segment") - data[0] = self._buffer - return self.buffer_size - - def __getwritebuffer__(self, Py_ssize_t index, void **data): - if index: - raise RuntimeError("accessing non-existent buffer segment") - data[0] = self._buffer - return self.buffer_size - - # New-style buffer support. - - def __getbuffer__(self, Py_buffer *view, int flags): - PyBuffer_FillInfo(view, self, self._buffer, self.buffer_size, 0, flags) - - -cdef class TextSubtitle(Subtitle): - - def __repr__(self): - return '<%s.%s %r at 0x%x>' % ( - self.__class__.__module__, - self.__class__.__name__, - self.text, - id(self), - ) - - property text: - def __get__(self): return self.ptr.text - - -cdef class AssSubtitle(Subtitle): - - def __repr__(self): - return '<%s.%s %r at 0x%x>' % ( - self.__class__.__module__, - self.__class__.__name__, - self.ass, - id(self), - ) - - property ass: - def __get__(self): return self.ptr.ass diff --git a/av/utils.pxd b/av/utils.pxd index 1f4a254f6..60279e513 100644 --- a/av/utils.pxd +++ b/av/utils.pxd @@ -1,13 +1,9 @@ -from libc.stdint cimport int64_t, uint8_t, uint64_t cimport libav as lib cdef dict avdict_to_dict(lib.AVDictionary *input, str encoding, str errors) cdef dict_to_avdict(lib.AVDictionary **dst, dict src, str encoding, str errors) - cdef object avrational_to_fraction(const lib.AVRational *input) -cdef object to_avrational(object value, lib.AVRational *input) - - -cdef flag_in_bitfield(uint64_t bitfield, uint64_t flag) +cdef void to_avrational(object frac, lib.AVRational *input) +cdef void check_ndarray(object array, object dtype, int ndim) diff --git a/av/utils.py b/av/utils.py new file mode 100644 index 000000000..300d315f2 --- /dev/null +++ b/av/utils.py @@ -0,0 +1,74 @@ +# type: ignore +from fractions import Fraction + +import cython +from cython.cimports import libav as lib +from cython.cimports.av.error import err_check + + +@cython.cfunc +def _decode(s: cython.pointer[cython.char], encoding, errors) -> str: + return cython.cast(bytes, s).decode(encoding, errors) + + +@cython.cfunc +def avdict_to_dict( + input: cython.pointer[lib.AVDictionary], encoding: str, errors: str +) -> dict: + element: cython.pointer[lib.AVDictionaryEntry] = cython.NULL + output: dict = {} + while True: + element = lib.av_dict_get(input, "", element, lib.AV_DICT_IGNORE_SUFFIX) + if element == cython.NULL: + break + output[_decode(element.key, encoding, errors)] = _decode( + element.value, encoding, errors + ) + + return output + + +@cython.cfunc +def dict_to_avdict( + dst: cython.pointer[cython.pointer[lib.AVDictionary]], + src: dict, + encoding: str, + errors: str, +): + lib.av_dict_free(dst) + for key, value in src.items(): + err_check( + lib.av_dict_set( + dst, key.encode(encoding, errors), value.encode(encoding, errors), 0 + ) + ) + + +@cython.cfunc +def avrational_to_fraction( + input: cython.pointer[cython.const[lib.AVRational]], +) -> object: + if input.num and input.den: + return Fraction(input.num, input.den) + return None + + +@cython.cfunc +def to_avrational(frac: object, input: cython.pointer[lib.AVRational]) -> cython.void: + input.num = frac.numerator + input.den = frac.denominator + + +@cython.cfunc +def check_ndarray(array: object, dtype: object, ndim: cython.int) -> cython.void: + """ + Check a numpy array has the expected data type and number of dimensions. + """ + if array.dtype != dtype: + raise ValueError( + f"Expected numpy array with dtype `{dtype}` but got `{array.dtype}`" + ) + if array.ndim != ndim: + raise ValueError( + f"Expected numpy array with ndim `{ndim}` but got `{array.ndim}`" + ) diff --git a/av/utils.pyx b/av/utils.pyx deleted file mode 100644 index 85b71a281..000000000 --- a/av/utils.pyx +++ /dev/null @@ -1,74 +0,0 @@ -from libc.stdint cimport int64_t, uint8_t, uint64_t - -from fractions import Fraction - -cimport libav as lib - -from av.error cimport err_check - - -# === DICTIONARIES === -# ==================== - -cdef _decode(char *s, encoding, errors): - return (s).decode(encoding, errors) - -cdef bytes _encode(s, encoding, errors): - return s.encode(encoding, errors) - -cdef dict avdict_to_dict(lib.AVDictionary *input, str encoding, str errors): - cdef lib.AVDictionaryEntry *element = NULL - cdef dict output = {} - while True: - element = lib.av_dict_get(input, "", element, lib.AV_DICT_IGNORE_SUFFIX) - if element == NULL: - break - output[_decode(element.key, encoding, errors)] = _decode(element.value, encoding, errors) - return output - - -cdef dict_to_avdict(lib.AVDictionary **dst, dict src, str encoding, str errors): - lib.av_dict_free(dst) - for key, value in src.items(): - err_check(lib.av_dict_set(dst, _encode(key, encoding, errors), - _encode(value, encoding, errors), 0)) - - -# === FRACTIONS === -# ================= - -cdef object avrational_to_fraction(const lib.AVRational *input): - if input.num and input.den: - return Fraction(input.num, input.den) - - -cdef object to_avrational(object value, lib.AVRational *input): - - if value is None: - input.num = 0 - input.den = 1 - return - - if isinstance(value, Fraction): - frac = value - else: - frac = Fraction(value) - - input.num = frac.numerator - input.den = frac.denominator - - -# === OTHER === -# ============= - -cdef flag_in_bitfield(uint64_t bitfield, uint64_t flag): - # Not every flag exists in every version of FFMpeg, so we define them to 0. - if not flag: - return None - return bool(bitfield & flag) - - -# === BACKWARDS COMPAT === - -from .error import FFmpegError as AVError -from .error import err_check diff --git a/av/video/__init__.pxd b/av/video/__init__.pxd new file mode 100644 index 000000000..e69de29bb diff --git a/av/video/__init__.py b/av/video/__init__.py index 4a25d8837..f781103fa 100644 --- a/av/video/__init__.py +++ b/av/video/__init__.py @@ -1,2 +1,2 @@ -from .frame import VideoFrame -from .stream import VideoStream +from .frame import VideoFrame as VideoFrame +from .stream import VideoStream as VideoStream diff --git a/av/video/__init__.pyi b/av/video/__init__.pyi new file mode 100644 index 000000000..63b5e0501 --- /dev/null +++ b/av/video/__init__.pyi @@ -0,0 +1,197 @@ +from typing import Literal + +from .frame import VideoFrame +from .stream import VideoStream + +# FFmpeg 8.1 encoders and the codec descriptor aliases that resolve to them. +_VideoCodecName = Literal[ + "a64_multi", + "a64_multi5", + "a64multi", + "a64multi5", + "alias_pix", + "amv", + "apng", + "apv", + "asv1", + "asv2", + "av1", + "av1_amf", + "av1_d3d12va", + "av1_mediacodec", + "av1_mf", + "av1_nvenc", + "av1_qsv", + "av1_vaapi", + "av1_vulkan", + "avrp", + "avs2", + "avui", + "bitpacked", + "bmp", + "cavs", + "cfhd", + "cinepak", + "cljr", + "dirac", + "dnxhd", + "dpx", + "dvvideo", + "dxv", + "evc", + "exr", + "ffv1", + "ffv1_vulkan", + "ffvhuff", + "fits", + "flashsv", + "flashsv2", + "flv", + "flv1", + "gif", + "h261", + "h263", + "h263_v4l2m2m", + "h263p", + "h264", + "h264_amf", + "h264_d3d12va", + "h264_mediacodec", + "h264_mf", + "h264_nvenc", + "h264_oh", + "h264_omx", + "h264_qsv", + "h264_rkmpp", + "h264_v4l2m2m", + "h264_vaapi", + "h264_videotoolbox", + "h264_vulkan", + "hap", + "hdr", + "hevc", + "hevc_amf", + "hevc_d3d12va", + "hevc_mediacodec", + "hevc_mf", + "hevc_nvenc", + "hevc_oh", + "hevc_qsv", + "hevc_rkmpp", + "hevc_v4l2m2m", + "hevc_vaapi", + "hevc_videotoolbox", + "hevc_vulkan", + "huffyuv", + "jpeg2000", + "jpegls", + "jpegxl", + "jpegxl_anim", + "jpegxs", + "libaom-av1", + "libjxl", + "libjxl_anim", + "libkvazaar", + "liboapv", + "libopenh264", + "libopenjpeg", + "librav1e", + "libsvtav1", + "libsvtjpegxs", + "libtheora", + "libvpx", + "libvpx-vp9", + "libvvenc", + "libwebp", + "libwebp_anim", + "libx262", + "libx264", + "libx264rgb", + "libx265", + "libxavs", + "libxavs2", + "libxeve", + "libxvid", + "ljpeg", + "magicyuv", + "mjpeg", + "mjpeg_qsv", + "mjpeg_vaapi", + "mpeg1video", + "mpeg2_qsv", + "mpeg2_vaapi", + "mpeg2video", + "mpeg4", + "mpeg4_mediacodec", + "mpeg4_omx", + "mpeg4_v4l2m2m", + "msmpeg4", + "msmpeg4v2", + "msmpeg4v3", + "msrle", + "msvideo1", + "pam", + "pbm", + "pcx", + "pfm", + "pgm", + "pgmyuv", + "phm", + "png", + "ppm", + "prores", + "prores_aw", + "prores_ks", + "prores_ks_vulkan", + "prores_videotoolbox", + "qoi", + "qtrle", + "r10k", + "r210", + "rawvideo", + "roq", + "roqvideo", + "rpza", + "rv10", + "rv20", + "sgi", + "smc", + "snow", + "speedhq", + "sunrast", + "svq1", + "targa", + "theora", + "tiff", + "utvideo", + "v210", + "v308", + "v408", + "v410", + "vbn", + "vc2", + "vnull", + "vp8", + "vp8_mediacodec", + "vp8_v4l2m2m", + "vp8_vaapi", + "vp9", + "vp9_mediacodec", + "vp9_qsv", + "vp9_vaapi", + "vvc", + "wbmp", + "webp", + "wmv1", + "wmv2", + "wrapped_avframe", + "xbm", + "xface", + "xwd", + "y41p", + "yuv4", + "zlib", + "zmbv", +] + +__all__ = ("VideoFrame", "VideoStream") diff --git a/av/video/codeccontext.pxd b/av/video/codeccontext.pxd index 9693caa9b..d15a9fc02 100644 --- a/av/video/codeccontext.pxd +++ b/av/video/codeccontext.pxd @@ -1,3 +1,4 @@ +cimport libav as lib from av.codec.context cimport CodecContext from av.video.format cimport VideoFormat @@ -5,17 +6,17 @@ from av.video.frame cimport VideoFrame from av.video.reformatter cimport VideoReformatter -cdef class VideoCodecContext(CodecContext): +# The get_format callback in AVCodecContext is called by the decoder to pick a format out of a list. +# When we want accelerated decoding, we need to figure out ahead of time what the format should be, +# and find a way to pass that into our callback so we can return it to the decoder. We use the 'opaque' +# user data field in AVCodecContext for that. This is the struct we store a pointer to in that field. +cdef struct AVCodecPrivateData: + lib.AVPixelFormat hardware_pix_fmt + bint allow_software_fallback - cdef VideoFormat _format - cdef _build_format(self) - cdef int last_w - cdef int last_h +cdef class VideoCodecContext(CodecContext): + cdef AVCodecPrivateData _private_data cdef readonly VideoReformatter reformatter - - # For encoding. - cdef readonly int encoded_frame_count - - # For decoding. cdef VideoFrame next_frame + cdef VideoFrame _encode_upload_frame(self, VideoFrame vframe) diff --git a/av/video/codeccontext.py b/av/video/codeccontext.py new file mode 100644 index 000000000..27dbaacb9 --- /dev/null +++ b/av/video/codeccontext.py @@ -0,0 +1,497 @@ +import cython +import cython.cimports.libav as lib +from cython.cimports.av.codec.context import CodecContext +from cython.cimports.av.codec.hwaccel import HWAccel +from cython.cimports.av.error import err_check +from cython.cimports.av.frame import Frame +from cython.cimports.av.packet import Packet +from cython.cimports.av.utils import avrational_to_fraction, to_avrational +from cython.cimports.av.video.format import VideoFormat, get_pix_fmt, get_video_format +from cython.cimports.av.video.frame import VideoFrame, alloc_video_frame +from cython.cimports.av.video.reformatter import VideoReformatter +from cython.cimports.libc.stdint import int64_t + + +@cython.cfunc +@cython.exceptval(check=False) +def _get_hw_format( + ctx: cython.pointer[lib.AVCodecContext], + pix_fmts: cython.pointer[cython.const[lib.AVPixelFormat]], +) -> lib.AVPixelFormat: + # In the case where we requested accelerated decoding, the decoder first calls this function + # with a list that includes both the hardware format and software formats. + # First we try to pick the hardware format if it's in the list. + # However, if the decoder fails to initialize the hardware, it will call this function again, + # with only software formats in pix_fmts. We return ctx->sw_pix_fmt regardless in this case, + # because that should be in the candidate list. If not, we are out of ideas anyways. + private_data: cython.pointer[AVCodecPrivateData] = cython.cast( + cython.pointer[AVCodecPrivateData], ctx.opaque + ) + i: cython.int = 0 + while pix_fmts[i] != -1: + if pix_fmts[i] == private_data.hardware_pix_fmt: + return pix_fmts[i] + i += 1 + return ( + ctx.sw_pix_fmt if private_data.allow_software_fallback else lib.AV_PIX_FMT_NONE + ) + + +@cython.final +@cython.cclass +class VideoCodecContext(CodecContext): + @cython.cfunc + def _init( + self, + ptr: cython.pointer[lib.AVCodecContext], + codec: cython.pointer[cython.const[lib.AVCodec]], + hwaccel: HWAccel | None, + ): + CodecContext._init(self, ptr, codec, hwaccel) + + if hwaccel is None: + return + + if self.is_encoder: + # Hardware-accelerated encoding. We only attach the device context here; + # the hardware frames context depends on the final width/height/pixel + # format (set by the user after add_stream()), so it is built lazily in + # CodecContext.open() via _setup_encode_hwframes(). + self.hwaccel_ctx = hwaccel.create(self.codec, for_encoding=True) + self.ptr.hw_device_ctx = lib.av_buffer_ref(self.hwaccel_ctx.ptr) + return + + try: + self.hwaccel_ctx = hwaccel.create(self.codec) + self.ptr.hw_device_ctx = lib.av_buffer_ref(self.hwaccel_ctx.ptr) + self.ptr.pix_fmt = self.hwaccel_ctx.config.ptr.pix_fmt + self.ptr.get_format = _get_hw_format + self._private_data.hardware_pix_fmt = self.hwaccel_ctx.config.ptr.pix_fmt + self._private_data.allow_software_fallback = ( + self.hwaccel.allow_software_fallback + ) + self.ptr.opaque = cython.address(self._private_data) + except NotImplementedError: + # Some streams may not have a hardware decoder. For example, many action + # cam videos have a low resolution mjpeg stream, which is usually not + # compatible with hardware decoders. + # The user may have passed in a hwaccel because they want to decode the main + # stream with it, so we shouldn't abort even if we find a stream that can't + # be HW decoded. + # If the user wants to make sure hwaccel is actually used, they can check with the + # is_hwaccel() function on each stream's codec context. + self.hwaccel_ctx = None + + @cython.cfunc + def _encode_upload_frame(self, vframe: VideoFrame) -> VideoFrame: + # Upload a software frame onto the device for hardware-accelerated encoding. + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data + ) + + # If the user already handed us a matching hardware frame, pass it through. + if vframe.ptr.format == frames_ctx.format: + return vframe + + # Convert to the frames context's software format and size before uploading, + # since av_hwframe_transfer_data() does not change pixel format or scale. + if ( + vframe.ptr.format != frames_ctx.sw_format + or vframe.ptr.width != frames_ctx.width + or vframe.ptr.height != frames_ctx.height + ): + if not self.reformatter: + self.reformatter = VideoReformatter() + vframe = self.reformatter.reformat( + vframe, + frames_ctx.width, + frames_ctx.height, + get_video_format( + frames_ctx.sw_format, frames_ctx.width, frames_ctx.height + ), + threads=self.ptr.thread_count, + ) + + hwframe: VideoFrame = alloc_video_frame() + err_check(lib.av_hwframe_get_buffer(self.ptr.hw_frames_ctx, hwframe.ptr, 0)) + err_check(lib.av_hwframe_transfer_data(hwframe.ptr, vframe.ptr, 0)) + hwframe._copy_internal_attributes(vframe, data_layout=False) + hwframe._init_user_attributes() + + if hwframe.ptr.pts == lib.AV_NOPTS_VALUE: + hwframe.ptr.pts = self.ptr.frame_num + + return hwframe + + @cython.cfunc + def _prepare_frames_for_encode(self, input: Frame | None) -> list: + if input is None or not input: + return [None] + + vframe: VideoFrame = input + + # Hardware-accelerated encoding: upload the (software) frame to the device. + if self.ptr.hw_frames_ctx != cython.NULL: + return [self._encode_upload_frame(vframe)] + + if ( + vframe.format.pix_fmt != self.pix_fmt + or vframe.width != self.ptr.width + or vframe.height != self.ptr.height + ): + if not self.reformatter: + self.reformatter = VideoReformatter() + + vframe = self.reformatter.reformat( + vframe, + self.ptr.width, + self.ptr.height, + self.format, + threads=self.ptr.thread_count, + ) + + if vframe.ptr.pts == lib.AV_NOPTS_VALUE: + vframe.ptr.pts = self.ptr.frame_num + + return [vframe] + + @cython.cfunc + def _alloc_next_frame(self) -> Frame: + return alloc_video_frame() + + @cython.cfunc + def _setup_decoded_frame(self, frame: Frame, packet: Packet): + CodecContext._setup_decoded_frame(self, frame, packet) + vframe: VideoFrame = frame + vframe._init_user_attributes() + + @cython.cfunc + def _transfer_hwframe(self, frame: Frame): + if self.hwaccel_ctx is None: + return frame + if frame.ptr.format != self.hwaccel_ctx.config.ptr.pix_fmt: + # If we get a software frame, that means we are in software fallback mode, and don't actually + # need to transfer. + return frame + + if self.hwaccel_ctx.is_hw_owned: + cython.cast(VideoFrame, frame)._device_id = self.hwaccel_ctx.device_id + return frame + + frame_sw: Frame = self._alloc_next_frame() + err_check(lib.av_hwframe_transfer_data(frame_sw.ptr, frame.ptr, 0)) + frame_sw._copy_internal_attributes(frame, data_layout=False) + return frame_sw + + @property + def format(self): + return get_video_format( + cython.cast(lib.AVPixelFormat, self.ptr.pix_fmt), + self.ptr.width, + self.ptr.height, + ) + + @format.setter + def format(self, format: VideoFormat): + self._assert_not_open("format") + self.ptr.pix_fmt = format.pix_fmt + self.ptr.width = format.width + self.ptr.height = format.height + + @property + def width(self): + if self.ptr is cython.NULL: + return 0 + return self.ptr.width + + @width.setter + def width(self, value: cython.uint): + self._assert_not_open("width") + self.ptr.width = value + + @property + def height(self): + if self.ptr is cython.NULL: + return 0 + return self.ptr.height + + @height.setter + def height(self, value: cython.uint): + self._assert_not_open("height") + self.ptr.height = value + + @property + def bits_per_coded_sample(self): + """ + The number of bits per sample in the codedwords. It's mandatory for this to be set for some formats to decode properly. + + Wraps :ffmpeg:`AVCodecContext.bits_per_coded_sample`. + + :type: int + """ + return self.ptr.bits_per_coded_sample + + @bits_per_coded_sample.setter + def bits_per_coded_sample(self, value: cython.int): + if self.is_encoder: + raise ValueError("Not supported for encoders") + + self.ptr.bits_per_coded_sample = value + + @property + def pix_fmt(self): + """ + The pixel format's name. + + :type: str | None + """ + desc: cython.pointer[cython.const[lib.AVPixFmtDescriptor]] = ( + lib.av_pix_fmt_desc_get(cython.cast(lib.AVPixelFormat, self.ptr.pix_fmt)) + ) + if desc == cython.NULL: + return None + return cython.cast(str, desc.name) + + @pix_fmt.setter + def pix_fmt(self, value): + self._assert_not_open("pix_fmt") + self.ptr.pix_fmt = get_pix_fmt(value) + + @property + def sw_format(self): + """ + For a hardware context (e.g. ``format.name == "cuda"``), the underlying + software pixel format (``nv12``, ``p010le``, ...). ``None`` for a regular + software context. + + :type: VideoFormat | None + """ + if not self.ptr.hw_frames_ctx: + if self.ptr.sw_pix_fmt != lib.AV_PIX_FMT_NONE: + return get_video_format( + cython.cast(lib.AVPixelFormat, self.ptr.sw_pix_fmt), + self.ptr.width, + self.ptr.height, + ) + return None + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data + ) + return get_video_format(frames_ctx.sw_format, self.ptr.width, self.ptr.height) + + @sw_format.setter + def sw_format(self, value): + self._assert_not_open("sw_format") + self.ptr.sw_pix_fmt = get_pix_fmt(value) + + @property + def framerate(self): + """ + The frame rate, in frames per second. + + :type: fractions.Fraction + """ + return avrational_to_fraction(cython.address(self.ptr.framerate)) + + @framerate.setter + def framerate(self, value): + self._assert_not_open("framerate") + to_avrational(value, cython.address(self.ptr.framerate)) + + @property + def rate(self): + """Another name for :attr:`framerate`.""" + return self.framerate + + @rate.setter + def rate(self, value): + self.framerate = value + + @property + def gop_size(self): + """ + Sets the number of frames between keyframes. Used only for encoding. + + :type: int + """ + if self.is_decoder: + raise RuntimeError("Cannot access 'gop_size' as a decoder") + return self.ptr.gop_size + + @gop_size.setter + def gop_size(self, value: cython.int): + if self.is_decoder: + raise RuntimeError("Cannot access 'gop_size' as a decoder") + self.ptr.gop_size = value + + @property + def sample_aspect_ratio(self): + return avrational_to_fraction(cython.address(self.ptr.sample_aspect_ratio)) + + @sample_aspect_ratio.setter + def sample_aspect_ratio(self, value): + to_avrational(value, cython.address(self.ptr.sample_aspect_ratio)) + + @property + def display_aspect_ratio(self): + dar: lib.AVRational + lib.av_reduce( + cython.address(dar.num), + cython.address(dar.den), + self.ptr.width * self.ptr.sample_aspect_ratio.num, + self.ptr.height * self.ptr.sample_aspect_ratio.den, + 1024 * 1024, + ) + + return avrational_to_fraction(cython.address(dar)) + + @property + def has_b_frames(self): + """ + :type: bool + """ + return bool(self.ptr.has_b_frames) + + @property + def reorder_depth(self): + """Raw ``has_b_frames`` value from FFmpeg (int, not bool). + + After :meth:`flush_buffers`, FFmpeg may reset the internal reorder + heuristic. Set this to the known reorder depth *after* seeking to + avoid dropped hierarchical B-frames. + """ + return self.ptr.has_b_frames + + @reorder_depth.setter + def reorder_depth(self, value: cython.int): + self.ptr.has_b_frames = value + + @property + def coded_width(self): + """ + :type: int + """ + return self.ptr.coded_width + + @property + def coded_height(self): + """ + :type: int + """ + return self.ptr.coded_height + + @property + def color_range(self): + """ + Describes the signal range of the colorspace. + + Wraps :ffmpeg:`AVFrame.color_range`. + + :type: int + """ + return self.ptr.color_range + + @color_range.setter + def color_range(self, value): + self.ptr.color_range = value + + @property + def color_primaries(self): + """ + Describes the RGB/XYZ matrix of the colorspace. + + Wraps :ffmpeg:`AVFrame.color_primaries`. + + :type: int + """ + return self.ptr.color_primaries + + @color_primaries.setter + def color_primaries(self, value): + self.ptr.color_primaries = value + + @property + def color_trc(self): + """ + Describes the linearization function (a.k.a. transformation characteristics) of the colorspace. + + Wraps :ffmpeg:`AVFrame.color_trc`. + + :type: int + """ + return self.ptr.color_trc + + @color_trc.setter + def color_trc(self, value): + self.ptr.color_trc = value + + @property + def colorspace(self): + """ + Describes the YUV/RGB transformation matrix of the colorspace. + + Wraps :ffmpeg:`AVFrame.colorspace`. + + :type: int + """ + return self.ptr.colorspace + + @colorspace.setter + def colorspace(self, value): + self.ptr.colorspace = value + + @property + def field_order(self): + """The video field order as FFmpeg's raw integer value. + + Wraps :ffmpeg:`AVCodecContext.field_order`. + + """ + return self.ptr.field_order + + @field_order.setter + def field_order(self, value: cython.int): + self.ptr.field_order = cython.cast(lib.AVFieldOrder, value) + + @property + def max_b_frames(self): + """ + The maximum run of consecutive B frames when encoding a video. + + :type: int + """ + return self.ptr.max_b_frames + + @max_b_frames.setter + def max_b_frames(self, value): + self.ptr.max_b_frames = value + + @property + def qmin(self): + """ + The minimum quantiser value of an encoded stream. + + Wraps :ffmpeg:`AVCodecContext.qmin`. + + :type: int + """ + return self.ptr.qmin + + @qmin.setter + def qmin(self, value): + self.ptr.qmin = value + + @property + def qmax(self): + """ + The maximum quantiser value of an encoded stream. + + Wraps :ffmpeg:`AVCodecContext.qmax`. + + :type: int + """ + return self.ptr.qmax + + @qmax.setter + def qmax(self, value): + self.ptr.qmax = value diff --git a/av/video/codeccontext.pyi b/av/video/codeccontext.pyi new file mode 100644 index 000000000..6f11c9aa9 --- /dev/null +++ b/av/video/codeccontext.pyi @@ -0,0 +1,42 @@ +from collections.abc import Iterator +from fractions import Fraction +from typing import Literal + +from av.codec.context import CodecContext +from av.packet import Packet + +from .format import VideoFormat +from .frame import VideoFrame + +class VideoCodecContext(CodecContext): + format: VideoFormat | None + width: int + height: int + bits_per_coded_sample: int + pix_fmt: str | None + @property + def sw_format(self) -> VideoFormat | None: ... + @sw_format.setter + def sw_format(self, value: str) -> None: ... + framerate: Fraction + rate: Fraction + gop_size: int + sample_aspect_ratio: Fraction | None + display_aspect_ratio: Fraction | None + has_b_frames: bool + reorder_depth: int + max_b_frames: int + coded_width: int + coded_height: int + color_range: int + color_primaries: int + color_trc: int + colorspace: int + field_order: int + qmin: int + qmax: int + type: Literal["video"] + + def encode(self, frame: VideoFrame | None = None) -> list[Packet]: ... + def encode_lazy(self, frame: VideoFrame | None = None) -> Iterator[Packet]: ... + def decode(self, packet: Packet | None = None) -> list[VideoFrame]: ... diff --git a/av/video/codeccontext.pyx b/av/video/codeccontext.pyx deleted file mode 100644 index f5eb9d07b..000000000 --- a/av/video/codeccontext.pyx +++ /dev/null @@ -1,160 +0,0 @@ -from libc.stdint cimport int64_t -cimport libav as lib - -from av.codec.context cimport CodecContext -from av.error cimport err_check -from av.frame cimport Frame -from av.packet cimport Packet -from av.utils cimport avrational_to_fraction, to_avrational -from av.video.format cimport VideoFormat, get_video_format -from av.video.frame cimport VideoFrame, alloc_video_frame -from av.video.reformatter cimport VideoReformatter - - -cdef class VideoCodecContext(CodecContext): - - def __cinit__(self, *args, **kwargs): - self.last_w = 0 - self.last_h = 0 - - cdef _init(self, lib.AVCodecContext *ptr, const lib.AVCodec *codec): - CodecContext._init(self, ptr, codec) # TODO: Can this be `super`? - self._build_format() - self.encoded_frame_count = 0 - - cdef _set_default_time_base(self): - self.ptr.time_base.num = self.ptr.framerate.den or 1 - self.ptr.time_base.den = self.ptr.framerate.num or lib.AV_TIME_BASE - - cdef _prepare_frames_for_encode(self, Frame input): - - if not input: - return [None] - - cdef VideoFrame vframe = input - - # Reformat if it doesn't match. - if ( - vframe.format.pix_fmt != self._format.pix_fmt or - vframe.width != self.ptr.width or - vframe.height != self.ptr.height - ): - if not self.reformatter: - self.reformatter = VideoReformatter() - vframe = self.reformatter.reformat( - vframe, - self.ptr.width, - self.ptr.height, - self._format, - ) - - # There is no pts, so create one. - if vframe.ptr.pts == lib.AV_NOPTS_VALUE: - vframe.ptr.pts = self.encoded_frame_count - - self.encoded_frame_count += 1 - - return [vframe] - - cdef Frame _alloc_next_frame(self): - return alloc_video_frame() - - cdef _setup_decoded_frame(self, Frame frame, Packet packet): - CodecContext._setup_decoded_frame(self, frame, packet) - cdef VideoFrame vframe = frame - vframe._init_user_attributes() - - cdef _build_format(self): - self._format = get_video_format(self.ptr.pix_fmt, self.ptr.width, self.ptr.height) - - property format: - def __get__(self): - return self._format - - def __set__(self, VideoFormat format): - self.ptr.pix_fmt = format.pix_fmt - self.ptr.width = format.width - self.ptr.height = format.height - self._build_format() # Kinda wasteful. - - property width: - def __get__(self): - return self.ptr.width - - def __set__(self, unsigned int value): - self.ptr.width = value - self._build_format() - - property height: - def __get__(self): - return self.ptr.height - - def __set__(self, unsigned int value): - self.ptr.height = value - self._build_format() - - # TODO: Replace with `format`. - property pix_fmt: - def __get__(self): - return self._format.name - - def __set__(self, value): - self.ptr.pix_fmt = lib.av_get_pix_fmt(value) - self._build_format() - - property framerate: - """ - The frame rate, in frames per second. - - :type: fractions.Fraction - """ - def __get__(self): - return avrational_to_fraction(&self.ptr.framerate) - - def __set__(self, value): - to_avrational(value, &self.ptr.framerate) - - property rate: - """Another name for :attr:`framerate`.""" - def __get__(self): - return self.framerate - - def __set__(self, value): - self.framerate = value - - property gop_size: - def __get__(self): - return self.ptr.gop_size - - def __set__(self, int value): - self.ptr.gop_size = value - - property sample_aspect_ratio: - def __get__(self): - return avrational_to_fraction(&self.ptr.sample_aspect_ratio) - - def __set__(self, value): - to_avrational(value, &self.ptr.sample_aspect_ratio) - - property display_aspect_ratio: - def __get__(self): - cdef lib.AVRational dar - - lib.av_reduce( - &dar.num, &dar.den, - self.ptr.width * self.ptr.sample_aspect_ratio.num, - self.ptr.height * self.ptr.sample_aspect_ratio.den, 1024*1024) - - return avrational_to_fraction(&dar) - - property has_b_frames: - def __get__(self): - return bool(self.ptr.has_b_frames) - - property coded_width: - def __get__(self): - return self.ptr.coded_width - - property coded_height: - def __get__(self): - return self.ptr.coded_height diff --git a/av/video/format.pxd b/av/video/format.pxd index f9110ae56..679525a2a 100644 --- a/av/video/format.pxd +++ b/av/video/format.pxd @@ -1,25 +1,20 @@ cimport libav as lib -cdef class VideoFormat(object): - +cdef class VideoFormat: cdef lib.AVPixelFormat pix_fmt cdef const lib.AVPixFmtDescriptor *ptr cdef readonly unsigned int width, height - - cdef readonly tuple components - cdef _init(self, lib.AVPixelFormat pix_fmt, unsigned int width, unsigned int height) - cpdef chroma_width(self, int luma_width=?) cpdef chroma_height(self, int luma_height=?) -cdef class VideoFormatComponent(object): - +cdef class VideoFormatComponent: cdef VideoFormat format cdef readonly unsigned int index cdef const lib.AVComponentDescriptor *ptr cdef VideoFormat get_video_format(lib.AVPixelFormat c_format, unsigned int width, unsigned int height) +cdef lib.AVPixelFormat get_pix_fmt(const char *name) except lib.AV_PIX_FMT_NONE diff --git a/av/video/format.py b/av/video/format.py new file mode 100644 index 000000000..3ab505051 --- /dev/null +++ b/av/video/format.py @@ -0,0 +1,209 @@ +import cython +from cython import uint as cuint + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def get_video_format( + c_format: lib.AVPixelFormat, width: cuint, height: cuint +) -> VideoFormat | None: + if c_format == lib.AV_PIX_FMT_NONE: + return None + + format: VideoFormat = VideoFormat.__new__(VideoFormat, _cinit_bypass_sentinel) + format._init(c_format, width, height) + return format + + +@cython.cfunc +@cython.exceptval(lib.AV_PIX_FMT_NONE, check=False) +def get_pix_fmt(name: cython.p_const_char) -> lib.AVPixelFormat: + """Wrapper for lib.av_get_pix_fmt with error checking.""" + + pix_fmt: lib.AVPixelFormat = lib.av_get_pix_fmt(name) + if pix_fmt == lib.AV_PIX_FMT_NONE: + raise ValueError(f"not a pixel format: {name!r}") + return pix_fmt + + +@cython.final +@cython.cclass +class VideoFormat: + """ + + >>> format = VideoFormat('rgb24') + >>> format.name + 'rgb24' + + """ + + def __cinit__(self, name, width=0, height=0): + if name is _cinit_bypass_sentinel: + return + + if isinstance(name, VideoFormat): + other: VideoFormat = cython.cast(VideoFormat, name) + self._init(other.pix_fmt, width or other.width, height or other.height) + return + + pix_fmt: lib.AVPixelFormat = get_pix_fmt(name) + self._init(pix_fmt, width, height) + + @cython.cfunc + def _init(self, pix_fmt: lib.AVPixelFormat, width: cuint, height: cuint): + self.pix_fmt = pix_fmt + self.ptr = lib.av_pix_fmt_desc_get(pix_fmt) + self.width = width + self.height = height + + def __repr__(self): + if self.width or self.height: + return f"" + else: + return f"" + + def __int__(self): + return int(self.pix_fmt) + + @property + def name(self): + """Canonical name of the pixel format.""" + return cython.cast(str, self.ptr.name) + + @property + def components(self): + return tuple( + VideoFormatComponent(self, i) for i in range(self.ptr.nb_components) + ) + + @property + def bits_per_pixel(self): + return lib.av_get_bits_per_pixel(self.ptr) + + @property + def padded_bits_per_pixel(self): + return lib.av_get_padded_bits_per_pixel(self.ptr) + + @property + def is_big_endian(self): + """Pixel format is big-endian.""" + return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_BE) + + @property + def has_palette(self): + """Pixel format has a palette in data[1], values are indexes in this palette.""" + return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_PAL) + + @property + def is_bit_stream(self): + """All values of a component are bit-wise packed end to end.""" + return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_BITSTREAM) + + @property + def is_planar(self): + """At least one pixel component is not in the first data plane.""" + return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_PLANAR) + + @property + def is_rgb(self): + """The pixel format contains RGB-like data (as opposed to YUV/grayscale).""" + return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_RGB) + + @property + def is_bayer(self): + """The pixel format contains Bayer data.""" + return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_BAYER) + + @cython.ccall + def chroma_width(self, luma_width: cython.int = 0): + """chroma_width(luma_width=0) + + Width of a chroma plane relative to a luma plane. + + :param int luma_width: Width of the luma plane; defaults to ``self.width``. + + """ + luma_width = luma_width or self.width + return -((-luma_width) >> self.ptr.log2_chroma_w) if luma_width else 0 + + @cython.ccall + def chroma_height(self, luma_height: cython.int = 0): + """chroma_height(luma_height=0) + + Height of a chroma plane relative to a luma plane. + + :param int luma_height: Height of the luma plane; defaults to ``self.height``. + + """ + luma_height = luma_height or self.height + return -((-luma_height) >> self.ptr.log2_chroma_h) if luma_height else 0 + + +@cython.final +@cython.cclass +class VideoFormatComponent: + def __cinit__(self, format: VideoFormat, index: cython.uint): + self.format = format + self.index = index + self.ptr = cython.address(format.ptr.comp[index]) + + @property + def plane(self): + """The index of the plane which contains this component.""" + return self.ptr.plane + + @property + def bits(self): + """Number of bits in the component.""" + return self.ptr.depth + + @property + def is_alpha(self): + """Is this component an alpha channel?""" + return (self.index == 1 and self.format.ptr.nb_components == 2) or ( + self.index == 3 and self.format.ptr.nb_components == 4 + ) + + @property + def is_luma(self): + """Is this component a luma channel?""" + return self.index == 0 and ( + self.format.ptr.nb_components == 1 + or self.format.ptr.nb_components == 2 + or not self.format.is_rgb + ) + + @property + def is_chroma(self): + """Is this component a chroma channel?""" + return (self.index == 1 or self.index == 2) and ( + self.format.ptr.log2_chroma_w or self.format.ptr.log2_chroma_h + ) + + @property + def width(self): + """The width of this component's plane. + + Requires the parent :class:`VideoFormat` to have a width. + + """ + return self.format.chroma_width() if self.is_chroma else self.format.width + + @property + def height(self): + """The height of this component's plane. + + Requires the parent :class:`VideoFormat` to have a height. + + """ + return self.format.chroma_height() if self.is_chroma else self.format.height + + +names = set() +desc = cython.declare(cython.pointer[cython.const[lib.AVPixFmtDescriptor]], cython.NULL) +while True: + desc = lib.av_pix_fmt_desc_next(desc) + if not desc: + break + names.add(desc.name) diff --git a/av/video/format.pyi b/av/video/format.pyi new file mode 100644 index 000000000..1ee0bc7a7 --- /dev/null +++ b/av/video/format.pyi @@ -0,0 +1,30 @@ +class VideoFormat: + name: str + bits_per_pixel: int + padded_bits_per_pixel: int + is_big_endian: bool + has_palette: bool + is_bit_stream: bool + is_planar: bool + width: int + height: int + @property + def components(self) -> tuple[VideoFormatComponent, ...]: ... + @property + def is_rgb(self) -> bool: ... + @property + def is_bayer(self) -> bool: ... + def __init__(self, name: str, width: int = 0, height: int = 0) -> None: ... + def chroma_width(self, luma_width: int = 0) -> int: ... + def chroma_height(self, luma_height: int = 0) -> int: ... + +class VideoFormatComponent: + plane: int + bits: int + is_alpha: bool + is_luma: bool + is_chroma: bool + width: int + height: int + + def __init__(self, format: VideoFormat, index: int) -> None: ... diff --git a/av/video/format.pyx b/av/video/format.pyx deleted file mode 100644 index fc7002579..000000000 --- a/av/video/format.pyx +++ /dev/null @@ -1,175 +0,0 @@ - -cdef object _cinit_bypass_sentinel = object() - -cdef VideoFormat get_video_format(lib.AVPixelFormat c_format, unsigned int width, unsigned int height): - if c_format == lib.AV_PIX_FMT_NONE: - return None - cdef VideoFormat format = VideoFormat.__new__(VideoFormat, _cinit_bypass_sentinel) - format._init(c_format, width, height) - return format - - -cdef class VideoFormat(object): - """ - - >>> format = VideoFormat('rgb24') - >>> format.name - 'rgb24' - - """ - - def __cinit__(self, name, width=0, height=0): - - if name is _cinit_bypass_sentinel: - return - - cdef VideoFormat other - if isinstance(name, VideoFormat): - other = name - self._init(other.pix_fmt, width or other.width, height or other.height) - return - - cdef lib.AVPixelFormat pix_fmt = lib.av_get_pix_fmt(name) - if pix_fmt < 0: - raise ValueError('not a pixel format: %r' % name) - self._init(pix_fmt, width, height) - - cdef _init(self, lib.AVPixelFormat pix_fmt, unsigned int width, unsigned int height): - self.pix_fmt = pix_fmt - self.ptr = lib.av_pix_fmt_desc_get(pix_fmt) - self.width = width - self.height = height - self.components = tuple( - VideoFormatComponent(self, i) - for i in range(self.ptr.nb_components) - ) - - def __repr__(self): - if self.width or self.height: - return '' % (self.__class__.__name__, self.name, self.width, self.height) - else: - return '' % (self.__class__.__name__, self.name) - - def __int__(self): - return int(self.pix_fmt) - - property name: - """Canonical name of the pixel format.""" - def __get__(self): - return self.ptr.name - - property bits_per_pixel: - def __get__(self): return lib.av_get_bits_per_pixel(self.ptr) - - property padded_bits_per_pixel: - def __get__(self): return lib.av_get_padded_bits_per_pixel(self.ptr) - - property is_big_endian: - """Pixel format is big-endian.""" - def __get__(self): return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_BE) - - property has_palette: - """Pixel format has a palette in data[1], values are indexes in this palette.""" - def __get__(self): return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_PAL) - - property is_bit_stream: - """All values of a component are bit-wise packed end to end.""" - def __get__(self): return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_BITSTREAM) - - # Skipping PIX_FMT_HWACCEL - # """Pixel format is an HW accelerated format.""" - - property is_planar: - """At least one pixel component is not in the first data plane.""" - def __get__(self): return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_PLANAR) - - property is_rgb: - """The pixel format contains RGB-like data (as opposed to YUV/grayscale).""" - def __get__(self): return bool(self.ptr.flags & lib.AV_PIX_FMT_FLAG_RGB) - - cpdef chroma_width(self, int luma_width=0): - """chroma_width(luma_width=0) - - Width of a chroma plane relative to a luma plane. - - :param int luma_width: Width of the luma plane; defaults to ``self.width``. - - """ - luma_width = luma_width or self.width - return -((-luma_width) >> self.ptr.log2_chroma_w) if luma_width else 0 - - cpdef chroma_height(self, int luma_height=0): - """chroma_height(luma_height=0) - - Height of a chroma plane relative to a luma plane. - - :param int luma_height: Height of the luma plane; defaults to ``self.height``. - - """ - luma_height = luma_height or self.height - return -((-luma_height) >> self.ptr.log2_chroma_h) if luma_height else 0 - - -cdef class VideoFormatComponent(object): - - def __cinit__(self, VideoFormat format, size_t index): - self.format = format - self.index = index - self.ptr = &format.ptr.comp[index] - - property plane: - """The index of the plane which contains this component.""" - def __get__(self): - return self.ptr.plane - - property bits: - """Number of bits in the component.""" - def __get__(self): - return self.ptr.depth - - property is_alpha: - """Is this component an alpha channel?""" - def __get__(self): - return ((self.index == 1 and self.format.ptr.nb_components == 2) or - (self.index == 3 and self.format.ptr.nb_components == 4)) - - property is_luma: - """Is this compoment a luma channel?""" - def __get__(self): - return self.index == 0 and ( - self.format.ptr.nb_components == 1 or - self.format.ptr.nb_components == 2 or - not self.format.is_rgb - ) - - property is_chroma: - """Is this component a chroma channel?""" - def __get__(self): - return (self.index == 1 or self.index == 2) and (self.format.ptr.log2_chroma_w or self.format.ptr.log2_chroma_h) - - property width: - """The width of this component's plane. - - Requires the parent :class:`VideoFormat` to have a width. - - """ - def __get__(self): - return self.format.chroma_width() if self.is_chroma else self.format.width - - property height: - """The height of this component's plane. - - Requires the parent :class:`VideoFormat` to have a height. - - """ - def __get__(self): - return self.format.chroma_height() if self.is_chroma else self.format.height - - -names = set() -cdef const lib.AVPixFmtDescriptor *desc = NULL -while True: - desc = lib.av_pix_fmt_desc_next(desc) - if not desc: - break - names.add(desc.name) diff --git a/av/video/frame.pxd b/av/video/frame.pxd index a08da1ecc..66926058d 100644 --- a/av/video/frame.pxd +++ b/av/video/frame.pxd @@ -1,22 +1,30 @@ -from libc.stdint cimport int16_t, int32_t, uint8_t, uint16_t, uint64_t cimport libav as lib +from libc.stdint cimport uint8_t, uintptr_t from av.frame cimport Frame from av.video.format cimport VideoFormat from av.video.reformatter cimport VideoReformatter -cdef class VideoFrame(Frame): - - # This is the buffer that is used to back everything in the AVFrame. - # We don't ever actually access it directly. - cdef uint8_t *_buffer +cdef class CudaContext: + cdef readonly int device_id + cdef readonly bint primary_ctx + cdef readonly bint current_ctx + cdef readonly uintptr_t cuda_stream + cdef lib.AVBufferRef* _device_ref + cdef dict _frames_cache + cdef lib.AVBufferRef* _get_device_ref(self) + cdef public lib.AVBufferRef* get_frames_ctx( + self, lib.AVPixelFormat sw_fmt, int width, int height + ) +cdef class VideoFrame(Frame): + cdef CudaContext _cuda_ctx cdef VideoReformatter reformatter - cdef readonly VideoFormat format - + cdef readonly int _device_id cdef _init(self, lib.AVPixelFormat format, unsigned int width, unsigned int height) cdef _init_user_attributes(self) + cpdef save(self, object filepath) cdef VideoFrame alloc_video_frame() diff --git a/av/video/frame.py b/av/video/frame.py new file mode 100644 index 000000000..74964107b --- /dev/null +++ b/av/video/frame.py @@ -0,0 +1,1925 @@ +import sys +from enum import IntEnum + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.dictionary import Dictionary +from cython.cimports.av.error import err_check +from cython.cimports.av.sidedata.sidedata import get_display_rotation +from cython.cimports.av.utils import check_ndarray +from cython.cimports.av.video.format import get_pix_fmt, get_video_format +from cython.cimports.av.video.plane import DLManagedTensor, VideoPlane, kCPU, kCuda +from cython.cimports.cpython.exc import PyErr_Clear +from cython.cimports.cpython.pycapsule import ( + PyCapsule_GetPointer, + PyCapsule_IsValid, + PyCapsule_SetName, +) +from cython.cimports.cpython.ref import Py_DECREF, Py_INCREF +from cython.cimports.hwcontext_cuda import AVCUDADeviceContext, CUstream +from cython.cimports.libc.stdint import int64_t, uint8_t, uintptr_t + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def _cuda_device_ctx_free( + device_ctx: cython.pointer[lib.AVHWDeviceContext], +) -> cython.void: + owner_ref: cython.pointer[lib.AVBufferRef] = cython.cast( + cython.pointer[lib.AVBufferRef], device_ctx.user_opaque + ) + lib.av_buffer_unref(cython.address(owner_ref)) + device_ctx.user_opaque = cython.NULL + + +@cython.final +@cython.cclass +class CudaContext: + """A reusable FFmpeg CUDA context for frames imported with DLPack. + + :param int device_id: CUDA device ordinal. + :param bool primary_ctx: Retain the device's primary CUDA context. + :param bool current_ctx: Wrap the CUDA context current on the calling thread + when this object is first used. This is mutually exclusive with + ``primary_ctx``. + :param int cuda_stream: Optional raw ``CUstream`` pointer for FFmpeg CUDA + operations, including NVENC input and output. The caller owns the + stream and must keep it alive as long as this context can be used. + Nonzero stream pointers are currently supported only for logical CUDA + device 0. To use another physical GPU, expose it as logical device 0 + via ``CUDA_VISIBLE_DEVICES``. + """ + + def __cinit__( + self, + device_id: cython.int = 0, + primary_ctx: cython.bint = True, + current_ctx: cython.bint = False, + cuda_stream: object = None, + ): + self.device_id = device_id + self.primary_ctx = primary_ctx + self.current_ctx = current_ctx + self.cuda_stream = 0 + self._device_ref = cython.NULL + self._frames_cache = {} + if primary_ctx and current_ctx: + raise ValueError("primary_ctx and current_ctx are mutually exclusive") + if cuda_stream is not None: + if not isinstance(cuda_stream, int): + raise TypeError("cuda_stream must be an integer or None") + if cuda_stream < 0: + raise ValueError("cuda_stream must be non-negative") + self.cuda_stream = cython.cast(uintptr_t, cuda_stream) + if self.cuda_stream and self.device_id != 0: + raise ValueError( + "cuda_stream currently requires device_id 0; expose the desired " + "physical GPU as logical device 0 via CUDA_VISIBLE_DEVICES" + ) + + def __dealloc__(self): + ref: cython.pointer[lib.AVBufferRef] + + for v in self._frames_cache.values(): + ref = cython.cast( + cython.pointer[lib.AVBufferRef], + cython.cast(cython.size_t, v), + ) + lib.av_buffer_unref(cython.address(ref)) + self._frames_cache.clear() + + ref = self._device_ref + if ref != cython.NULL: + lib.av_buffer_unref(cython.address(ref)) + self._device_ref = cython.NULL + + @cython.cfunc + def _get_device_ref(self) -> cython.pointer[lib.AVBufferRef]: + device_ref: cython.pointer[lib.AVBufferRef] = self._device_ref + owner_ref: cython.pointer[lib.AVBufferRef] + if device_ref != cython.NULL: + return device_ref + + owner_ref = cython.NULL + device_bytes = f"{self.device_id}".encode() + c_device: cython.p_char = device_bytes + options_dict = {"primary_ctx": "1" if self.primary_ctx else "0"} + if self.current_ctx: + options_dict["current_ctx"] = "1" + options: Dictionary = Dictionary(options_dict) + err_check( + lib.av_hwdevice_ctx_create( + cython.address(owner_ref), + lib.AV_HWDEVICE_TYPE_CUDA, + c_device, + options.ptr, + 0, + ) + ) + if not self.cuda_stream: + self._device_ref = owner_ref + return owner_ref + + device_ref = lib.av_hwdevice_ctx_alloc(lib.AV_HWDEVICE_TYPE_CUDA) + if device_ref == cython.NULL: + lib.av_buffer_unref(cython.address(owner_ref)) + raise MemoryError("av_hwdevice_ctx_alloc() failed") + + try: + owner_device_ctx: cython.pointer[lib.AVHWDeviceContext] = cython.cast( + cython.pointer[lib.AVHWDeviceContext], owner_ref.data + ) + owner_cuda_ctx: cython.pointer[AVCUDADeviceContext] = cython.cast( + cython.pointer[AVCUDADeviceContext], owner_device_ctx.hwctx + ) + device_ctx: cython.pointer[lib.AVHWDeviceContext] = cython.cast( + cython.pointer[lib.AVHWDeviceContext], device_ref.data + ) + cuda_ctx: cython.pointer[AVCUDADeviceContext] = cython.cast( + cython.pointer[AVCUDADeviceContext], device_ctx.hwctx + ) + cuda_ctx.cuda_ctx = owner_cuda_ctx.cuda_ctx + cuda_ctx.stream = cython.cast(CUstream, self.cuda_stream) + device_ctx.free = _cuda_device_ctx_free + device_ctx.user_opaque = cython.cast(cython.p_void, owner_ref) + owner_ref = cython.NULL + err_check(lib.av_hwdevice_ctx_init(device_ref)) + except Exception: + lib.av_buffer_unref(cython.address(device_ref)) + lib.av_buffer_unref(cython.address(owner_ref)) + raise + + self._device_ref = device_ref + return device_ref + + @cython.cfunc + def get_frames_ctx( + self, + sw_fmt: lib.AVPixelFormat, + width: cython.int, + height: cython.int, + ) -> cython.pointer[lib.AVBufferRef]: + key = (int(sw_fmt), int(width), int(height)) + cached = self._frames_cache.get(key) + cached_ref: cython.pointer[lib.AVBufferRef] + out_ref: cython.pointer[lib.AVBufferRef] + + if cached is not None: + cached_ref = cython.cast( + cython.pointer[lib.AVBufferRef], + cython.cast(cython.size_t, cached), + ) + out_ref = lib.av_buffer_ref(cached_ref) + if out_ref == cython.NULL: + raise MemoryError("av_buffer_ref() failed") + return out_ref + + device_ref = self._get_device_ref() + + frames_ref: cython.pointer[lib.AVBufferRef] = lib.av_hwframe_ctx_alloc( + device_ref + ) + if frames_ref == cython.NULL: + raise MemoryError("av_hwframe_ctx_alloc() failed") + + try: + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], frames_ref.data + ) + frames_ctx.format = get_pix_fmt(b"cuda") + frames_ctx.sw_format = sw_fmt + frames_ctx.width = int(width) + frames_ctx.height = int(height) + err_check(lib.av_hwframe_ctx_init(frames_ref)) + except Exception: + lib.av_buffer_unref(cython.address(frames_ref)) + raise + + out_ref = lib.av_buffer_ref(frames_ref) + if out_ref == cython.NULL: + lib.av_buffer_unref(cython.address(frames_ref)) + raise MemoryError("av_buffer_ref() failed") + + self._frames_cache[key] = cython.cast(cython.size_t, frames_ref) + return out_ref + + +@cython.cfunc +def _consume_dlpack(obj: object, stream: object) -> cython.pointer[DLManagedTensor]: + capsule: object + managed: cython.pointer[DLManagedTensor] + + if hasattr(obj, "__dlpack__"): + capsule = obj.__dlpack__() if stream is None else obj.__dlpack__(stream=stream) + else: + capsule = obj + + if not PyCapsule_IsValid(capsule, b"dltensor"): + PyErr_Clear() + raise TypeError( + "expected a DLPack capsule or an object implementing __dlpack__" + ) + + managed = cython.cast( + cython.pointer[DLManagedTensor], + PyCapsule_GetPointer(capsule, b"dltensor"), + ) + if managed == cython.NULL: + raise ValueError("PyCapsule_GetPointer returned NULL") + + if PyCapsule_SetName(capsule, b"used_dltensor") != 0: + raise RuntimeError("PyCapsule_SetName failed") + + return managed + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def _dlpack_avbuffer_free( + opaque: cython.p_void, + data: cython.pointer[uint8_t], +) -> cython.void: + managed: cython.pointer[DLManagedTensor] = cython.cast( + cython.pointer[DLManagedTensor], opaque + ) + if managed != cython.NULL: + managed.deleter(managed) + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def _numpy_avbuffer_free( + opaque: cython.p_void, + data: cython.pointer[uint8_t], +) -> cython.void: + if opaque != cython.NULL: + with cython.gil: + Py_DECREF(cython.cast(object, opaque)) + + +_cinit_bypass_sentinel = cython.declare(object, object()) + +# `pix_fmt`s supported by Frame.to_ndarray() and Frame.from_ndarray() +supported_np_pix_fmts = { + "abgr", + "argb", + "bayer_bggr16be", + "bayer_bggr16le", + "bayer_bggr8", + "bayer_gbrg16be", + "bayer_gbrg16le", + "bayer_gbrg8", + "bayer_grbg16be", + "bayer_grbg16le", + "bayer_grbg8", + "bayer_rggb16be", + "bayer_rggb16le", + "bayer_rggb8", + "bgr24", + "bgr48be", + "bgr48le", + "bgr8", + "bgra", + "bgra64be", + "bgra64le", + "gbrap", + "gbrap10be", + "gbrap10le", + "gbrap12be", + "gbrap12le", + "gbrap14be", + "gbrap14le", + "gbrap16be", + "gbrap16le", + "gbrapf32be", + "gbrapf32le", + "gbrp", + "gbrp10be", + "gbrp10le", + "gbrp12be", + "gbrp12le", + "gbrp14be", + "gbrp14le", + "gbrp16be", + "gbrp16le", + "gbrp9be", + "gbrp9le", + "gbrpf32be", + "gbrpf32le", + "gray", + "gray10be", + "gray10le", + "gray12be", + "gray12le", + "gray14be", + "gray14le", + "gray16be", + "gray16le", + "gray8", + "gray9be", + "gray9le", + "grayf32be", + "grayf32le", + "nv12", + "pal8", + "rgb24", + "rgb48be", + "rgb48le", + "rgb8", + "rgba", + "rgba64be", + "rgba64le", + "rgbaf16be", + "rgbaf16le", + "rgbaf32be", + "rgbaf32le", + "rgbf32be", + "rgbf32le", + "yuv420p", + "yuv420p10le", + "yuv422p10le", + "yuv444p", + "yuv444p16be", + "yuv444p16le", + "yuva444p16be", + "yuva444p16le", + "yuvj420p", + "yuvj444p", + "yuyv422", +} + +# Mapping from format name to (itemsize, dtype) for formats where planes +# are simply concatenated into shape (height, width, channels). +_np_pix_fmt_dtypes = cython.declare( + dict[str, tuple[cython.uint, str]], + { + "abgr": (4, "uint8"), + "argb": (4, "uint8"), + "bayer_bggr8": (1, "uint8"), + "bayer_gbrg8": (1, "uint8"), + "bayer_grbg8": (1, "uint8"), + "bayer_rggb8": (1, "uint8"), + "bayer_bggr16le": (2, "uint16"), + "bayer_bggr16be": (2, "uint16"), + "bayer_gbrg16le": (2, "uint16"), + "bayer_gbrg16be": (2, "uint16"), + "bayer_grbg16le": (2, "uint16"), + "bayer_grbg16be": (2, "uint16"), + "bayer_rggb16le": (2, "uint16"), + "bayer_rggb16be": (2, "uint16"), + "bgr24": (3, "uint8"), + "bgr48be": (6, "uint16"), + "bgr48le": (6, "uint16"), + "bgr8": (1, "uint8"), + "bgra": (4, "uint8"), + "bgra64be": (8, "uint16"), + "bgra64le": (8, "uint16"), + "gbrap": (1, "uint8"), + "gbrap10be": (2, "uint16"), + "gbrap10le": (2, "uint16"), + "gbrap12be": (2, "uint16"), + "gbrap12le": (2, "uint16"), + "gbrap14be": (2, "uint16"), + "gbrap14le": (2, "uint16"), + "gbrap16be": (2, "uint16"), + "gbrap16le": (2, "uint16"), + "gbrapf32be": (4, "float32"), + "gbrapf32le": (4, "float32"), + "gbrp": (1, "uint8"), + "gbrp10be": (2, "uint16"), + "gbrp10le": (2, "uint16"), + "gbrp12be": (2, "uint16"), + "gbrp12le": (2, "uint16"), + "gbrp14be": (2, "uint16"), + "gbrp14le": (2, "uint16"), + "gbrp16be": (2, "uint16"), + "gbrp16le": (2, "uint16"), + "gbrp9be": (2, "uint16"), + "gbrp9le": (2, "uint16"), + "gbrpf32be": (4, "float32"), + "gbrpf32le": (4, "float32"), + "gray": (1, "uint8"), + "gray10be": (2, "uint16"), + "gray10le": (2, "uint16"), + "gray12be": (2, "uint16"), + "gray12le": (2, "uint16"), + "gray14be": (2, "uint16"), + "gray14le": (2, "uint16"), + "gray16be": (2, "uint16"), + "gray16le": (2, "uint16"), + "gray8": (1, "uint8"), + "gray9be": (2, "uint16"), + "gray9le": (2, "uint16"), + "grayf32be": (4, "float32"), + "grayf32le": (4, "float32"), + "rgb24": (3, "uint8"), + "rgb48be": (6, "uint16"), + "rgb48le": (6, "uint16"), + "rgb8": (1, "uint8"), + "rgba": (4, "uint8"), + "rgba64be": (8, "uint16"), + "rgba64le": (8, "uint16"), + "rgbaf16be": (8, "float16"), + "rgbaf16le": (8, "float16"), + "rgbaf32be": (16, "float32"), + "rgbaf32le": (16, "float32"), + "rgbf32be": (12, "float32"), + "rgbf32le": (12, "float32"), + "yuv444p": (1, "uint8"), + "yuv444p16be": (2, "uint16"), + "yuv444p16le": (2, "uint16"), + "yuva444p16be": (2, "uint16"), + "yuva444p16le": (2, "uint16"), + "yuvj444p": (1, "uint8"), + "yuyv422": (2, "uint8"), + }, +) + + +@cython.cfunc +def alloc_video_frame() -> VideoFrame: + """Get a mostly uninitialized VideoFrame. + + You MUST call VideoFrame._init(...) or VideoFrame._init_user_attributes() + before exposing to the user. + + """ + return VideoFrame(_cinit_bypass_sentinel) + + +class PictureType(IntEnum): + NONE = lib.AV_PICTURE_TYPE_NONE # Undefined + I = lib.AV_PICTURE_TYPE_I # Intra + P = lib.AV_PICTURE_TYPE_P # Predicted + B = lib.AV_PICTURE_TYPE_B # Bi-directional predicted + S = lib.AV_PICTURE_TYPE_S # S(GMC)-VOP MPEG-4 + SI = lib.AV_PICTURE_TYPE_SI # Switching intra + SP = lib.AV_PICTURE_TYPE_SP # Switching predicted + BI = lib.AV_PICTURE_TYPE_BI # BI type + + +_is_big_endian = cython.declare(cython.bint, sys.byteorder == "big") + + +@cython.cfunc +@cython.inline +def byteswap_array(array, big_endian: cython.bint): + if _is_big_endian != big_endian: + return array.byteswap() + return array + + +@cython.cfunc +def copy_bytes_to_plane( + img_bytes, + plane: VideoPlane, + bytes_per_pixel: cython.uint, + flip_horizontal: cython.bint, + flip_vertical: cython.bint, +): + i_buf: cython.const[uint8_t][:] = img_bytes + i_pos: cython.size_t = 0 + i_stride: cython.size_t = plane.width * bytes_per_pixel + + o_buf: uint8_t[:] = plane + o_pos: cython.size_t = 0 + o_stride: cython.size_t = abs(plane.line_size) + + start_row, end_row, step = cython.declare(cython.int) + if flip_vertical: + start_row = plane.height - 1 + end_row = -1 + step = -1 + else: + start_row = 0 + end_row = plane.height + step = 1 + + for row in range(start_row, end_row, step): + i_pos = row * i_stride + if flip_horizontal: + i: cython.Py_ssize_t + for i in range(0, i_stride, bytes_per_pixel): + j: cython.Py_ssize_t + for j in range(bytes_per_pixel): + o_buf[o_pos + i + j] = i_buf[ + i_pos + i_stride - i - bytes_per_pixel + j + ] + else: + o_buf[o_pos : o_pos + i_stride] = i_buf[i_pos : i_pos + i_stride] + o_pos += o_stride + + +@cython.cfunc +def copy_array_to_plane(array, plane: VideoPlane, bytes_per_pixel: cython.uint): + imgbytes: bytes = array.tobytes() + copy_bytes_to_plane(imgbytes, plane, bytes_per_pixel, False, False) + + +@cython.cfunc +@cython.inline +def useful_array( + plane: VideoPlane, bytes_per_pixel: cython.uint = 1, dtype: str = "uint8" +): + """ + Return the useful part of the VideoPlane as a strided array. + + We are simply creating a view that discards any padding which was added for + alignment. + """ + import numpy as np + + dtype_obj = np.dtype(dtype) + line_size = plane.frame.ptr.linesize[plane.index] + total_line_size = abs(line_size) + itemsize = dtype_obj.itemsize + channels = bytes_per_pixel // itemsize + + if channels == 1: + shape = (plane.height, plane.width) + strides = (total_line_size, itemsize) + else: + shape = (plane.height, plane.width, channels) + strides = (total_line_size, bytes_per_pixel, itemsize) + + if line_size < 0: + offset = (plane.height - 1) * total_line_size + strides = (-total_line_size, *strides[1:]) + return np.ndarray( + shape, dtype=dtype_obj, buffer=plane, offset=offset, strides=strides + ) + + return np.ndarray(shape, dtype=dtype_obj, buffer=plane, strides=strides) + + +@cython.cfunc +def check_ndarray_shape(array: object, ok: cython.bint): + if not ok: + raise ValueError(f"Unexpected numpy array shape `{array.shape}`") + + +@cython.final +@cython.cclass +class VideoFrame(Frame): + def __cinit__(self, width=0, height=0, format="yuv420p"): + if width is _cinit_bypass_sentinel: + return + + c_format: lib.AVPixelFormat = get_pix_fmt(format) + self._init(c_format, width, height) + + @cython.cfunc + def _init(self, format: lib.AVPixelFormat, width: cython.uint, height: cython.uint): + res: cython.int = 0 + + with cython.nogil: + self.ptr.width = width + self.ptr.height = height + self.ptr.format = format + + # We enforce aligned buffers, otherwise `sws_scale_frame` can perform + # poorly or even cause out-of-bounds reads and writes. + if width and height: + res = lib.av_frame_get_buffer(self.ptr, 16) + + if res: + err_check(res) + + self._init_user_attributes() + + @cython.cfunc + def _init_user_attributes(self): + self.format = get_video_format( + cython.cast(lib.AVPixelFormat, self.ptr.format), + self.ptr.width, + self.ptr.height, + ) + + def __dealloc__(self): + lib.av_frame_unref(self.ptr) + + @property + def sw_format(self): + """ + For a hardware frame (e.g. ``format.name == "cuda"``), the underlying + software pixel format (``nv12``, ``yuv444p``, ``p010le``, ...). ``None`` + for a regular software frame. + + :type: VideoFormat | None + """ + if not self.ptr.hw_frames_ctx: + return None + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data + ) + return get_video_format(frames_ctx.sw_format, self.ptr.width, self.ptr.height) + + def __repr__(self): + return ( + f"" + ) + + @property + def planes(self): + """ + A tuple of :class:`.VideoPlane` objects. + """ + # We need to detect which planes actually exist, but also constrain ourselves to + # the maximum plane count (as determined only by VideoFrames so far), in case + # the library implementation does not set the last plane to NULL. + fmt = self.format + if self.ptr.hw_frames_ctx: + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data + ) + fmt = get_video_format( + frames_ctx.sw_format, self.ptr.width, self.ptr.height + ) + + max_plane_count: cython.int = 0 + for i in range(fmt.ptr.nb_components): + count = fmt.ptr.comp[i].plane + 1 + if max_plane_count < count: + max_plane_count = count + if fmt.name == "pal8": + max_plane_count = 2 + + plane_count: cython.int = 0 + while plane_count < max_plane_count and self.ptr.extended_data[plane_count]: + plane_count += 1 + if plane_count == 1: + return (VideoPlane(self, 0),) + return tuple([VideoPlane(self, i) for i in range(plane_count)]) + + @property + def width(self): + """Width of the image, in pixels.""" + return self.ptr.width + + @property + def height(self): + """Height of the image, in pixels.""" + return self.ptr.height + + @property + def rotation(self): + """The rotation component of the `DISPLAYMATRIX` transformation matrix. + + Returns: + int: The angle (in degrees) by which the transformation rotates the frame + counterclockwise. The angle will be in range [-180, 180]. + """ + return get_display_rotation(self) + + @property + def interlaced_frame(self): + """Is this frame an interlaced or progressive?""" + + return bool(self.ptr.flags & lib.AV_FRAME_FLAG_INTERLACED) + + @property + def pict_type(self): + """Returns an integer that corresponds to the PictureType enum. + + Wraps :ffmpeg:`AVFrame.pict_type` + + :type: int + """ + return self.ptr.pict_type + + @pict_type.setter + def pict_type(self, value): + self.ptr.pict_type = value + + @property + def colorspace(self): + """Colorspace of frame. + + Wraps :ffmpeg:`AVFrame.colorspace`. + + """ + return self.ptr.colorspace + + @colorspace.setter + def colorspace(self, value): + self.ptr.colorspace = value + + @property + def color_range(self): + """Color range of frame. + + Wraps :ffmpeg:`AVFrame.color_range`. + + """ + return self.ptr.color_range + + @color_range.setter + def color_range(self, value): + self.ptr.color_range = value + + @property + def color_trc(self): + """Transfer characteristic of frame. + + Wraps :ffmpeg:`AVFrame.color_trc`. + + """ + return self.ptr.color_trc + + @color_trc.setter + def color_trc(self, value): + self.ptr.color_trc = value + + @property + def color_primaries(self): + """Color primaries of frame. + + Wraps :ffmpeg:`AVFrame.color_primaries`. + + """ + return self.ptr.color_primaries + + @color_primaries.setter + def color_primaries(self, value): + self.ptr.color_primaries = value + + def reformat(self, *args, **kwargs): + """reformat(width=None, height=None, format=None, src_colorspace=None, dst_colorspace=None, interpolation=None, threads=None) + + Create a new :class:`VideoFrame` with the given width/height/format/colorspace. + + .. seealso:: :meth:`.VideoReformatter.reformat` for arguments. + + """ + if not self.reformatter: + self.reformatter = VideoReformatter() + return self.reformatter.reformat(self, *args, **kwargs) + + def to_rgb(self, **kwargs): + """Get an RGB version of this frame. + + Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`. + + >>> frame = VideoFrame(1920, 1080) + >>> frame.format.name + 'yuv420p' + >>> frame.to_rgb().format.name + 'rgb24' + + """ + return self.reformat(format="rgb24", **kwargs) + + @cython.ccall + def save(self, filepath: object): + """Save a VideoFrame as a JPG or PNG. + + :param filepath: str | Path + """ + is_jpg: cython.bint + + if filepath.endswith(".png"): + is_jpg = False + elif filepath.endswith(".jpg") or filepath.endswith(".jpeg"): + is_jpg = True + else: + raise ValueError("filepath must end with png or jpg.") + + encoder: str = "mjpeg" if is_jpg else "png" + pix_fmt: str = "yuvj420p" if is_jpg else "rgb24" + + from av.container.core import open + + with open(filepath, "w", options={"update": "1"}) as output: + output_stream = output.add_stream(encoder, pix_fmt=pix_fmt) + output_stream.width = self.width + output_stream.height = self.height + + output.mux(output_stream.encode(self.reformat(format=pix_fmt))) + output.mux(output_stream.encode(None)) + + def to_image(self, **kwargs): + """Get an RGB ``PIL.Image`` of this frame. + + Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`. + + .. note:: PIL or Pillow must be installed. + + """ + from PIL import Image + + plane: VideoPlane = self.reformat(format="rgb24", **kwargs).planes[0] + + i_buf: cython.const[uint8_t][:] = plane + line_size: cython.int = plane.line_size + i_stride: cython.size_t = abs(line_size) + + o_pos: cython.size_t = 0 + o_stride: cython.size_t = plane.width * 3 + o_size: cython.size_t = plane.height * o_stride + o_buf: bytearray = bytearray(o_size) + + # For bottom-up frames (negative line_size) the buffer protocol exposes + # rows from the lowest address, so the top display row is at the far end. + i_pos: cython.size_t = (plane.height - 1) * i_stride if line_size < 0 else 0 + + while o_pos < o_size: + o_buf[o_pos : o_pos + o_stride] = i_buf[i_pos : i_pos + o_stride] + if line_size < 0: + i_pos -= i_stride + else: + i_pos += i_stride + o_pos += o_stride + + return Image.frombytes( + "RGB", (plane.width, plane.height), bytes(o_buf), "raw", "RGB", 0, 1 + ) + + @cython.cdivision(True) + def to_ndarray(self, channel_last=False, **kwargs): + """Get a numpy array of this frame. + + Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`. + + The array returned is generally of dimension (height, width, channels). + + :param bool channel_last: If True, the shape of array will be + (height, width, channels) rather than (channels, height, width) for + the "yuv444p" and "yuvj444p" formats. + + .. note:: Numpy must be installed. + + .. note:: For formats which return an array of ``uint16``, ``float16`` or ``float32``, + the samples will be in the system's native byte order. + + .. note:: For ``pal8``, an ``(image, palette)`` tuple will be returned, + with the palette being in ARGB (PyAV will swap bytes if needed). + + .. note:: For ``gbrp`` formats, channels are flipped to RGB order. + + """ + if self.ptr.hw_frames_ctx and "format" not in kwargs: + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data + ) + kwargs = dict(kwargs) + kwargs["format"] = get_video_format( + frames_ctx.sw_format, self.ptr.width, self.ptr.height + ).name + + frame: VideoFrame = self.reformat(**kwargs) if len(kwargs) > 0 else self + if frame.ptr.hw_frames_ctx: + raise ValueError("Cannot convert a hardware frame to numpy directly.") + + import numpy as np + + # check size + format_name = frame.format.name + planes: tuple[VideoPlane, ...] = frame.planes + # cases planes are simply concatenated in shape (height, width, channels) + if format_name in _np_pix_fmt_dtypes: + if format_name == "yuyv422": + assert frame.ptr.width % 2 == 0, "width has to be even for yuyv422" + assert frame.ptr.height % 2 == 0, "height has to be even for yuyv422" + itemsize: cython.uint + itemsize, dtype = _np_pix_fmt_dtypes[format_name] + num_planes: cython.size_t = len(planes) + if num_planes == 1: # shortcut, avoid memory copy + array = useful_array(planes[0], itemsize, dtype) + else: # general case + array = np.empty( + (frame.ptr.height, frame.ptr.width, num_planes), dtype=dtype + ) + if format_name.startswith("gbr"): + plane_indices = (2, 0, 1, *range(3, num_planes)) + else: + plane_indices = range(num_planes) + for i, p_idx in enumerate(plane_indices): + array[:, :, i] = useful_array(planes[p_idx], itemsize, dtype) + array = byteswap_array(array, format_name.endswith("be")) + if not channel_last and format_name in {"yuv444p", "yuvj444p"}: + array = np.moveaxis(array, 2, 0) + return array + + # special cases + if format_name in {"yuv420p", "yuvj420p", "yuv422p", "yuv420p10le"}: + assert frame.ptr.width % 2 == 0, "width has to be even for this format" + assert frame.ptr.height % 2 == 0, "height has to be even for this format" + is_10bit: cython.bint = format_name == "yuv420p10le" + itemsize = 2 if is_10bit else 1 + dtype = "uint16" if is_10bit else "uint8" + if frame.ptr.width == 0 or frame.ptr.height == 0: + return np.empty((0, frame.ptr.width), dtype=dtype) + return np.hstack( + [ + useful_array(planes[0], itemsize, dtype).reshape(-1), + useful_array(planes[1], itemsize, dtype).reshape(-1), + useful_array(planes[2], itemsize, dtype).reshape(-1), + ] + ).reshape(-1, frame.ptr.width) + if format_name == "yuv422p10le": + assert frame.ptr.width % 2 == 0, "width has to be even for this format" + assert frame.ptr.height % 2 == 0, "height has to be even for this format" + # Read planes as uint16 at their original width + y = useful_array(planes[0], 2, "uint16") + u = useful_array(planes[1], 2, "uint16") + v = useful_array(planes[2], 2, "uint16") + + # Double the width of U and V by repeating each value + u_full = np.repeat(u, 2, axis=1) + v_full = np.repeat(v, 2, axis=1) + if channel_last: + return np.stack([y, u_full, v_full], axis=2) + return np.stack([y, u_full, v_full], axis=0) + if format_name == "pal8": + image = useful_array(planes[0]) + palette = ( + np.frombuffer(planes[1], "i4") + .astype(">i4") + .reshape(-1, 1) + .view(np.uint8) + ) + return image, palette + if format_name == "nv12": + return np.hstack( + [ + useful_array(planes[0]).reshape(-1), + useful_array(planes[1], 2).reshape(-1), + ] + ).reshape(-1, frame.ptr.width) + + raise ValueError( + f"Conversion to numpy array with format `{format_name}` is not yet supported" + ) + + def set_image(self, img): + """ + Update content from a ``PIL.Image``. + """ + if img.mode != "RGB": + img = img.convert("RGB") + + copy_array_to_plane(img, self.planes[0], 3) + + @staticmethod + def from_image(img): + """ + Construct a frame from a ``PIL.Image``. + """ + frame: VideoFrame = VideoFrame(img.size[0], img.size[1], "rgb24") + frame.set_image(img) + + return frame + + @staticmethod + def from_numpy_buffer(array, format="rgb24", width=0): + """ + Construct a frame from a numpy buffer. + + :param int width: optional width of actual image, if different from the array width. + + .. note:: For formats which expect an array of ``uint16``, ``float16`` or ``float32``, + the samples must be in the system's native byte order. + + .. note:: for ``gbrp`` formats, channels are assumed to be given in RGB order. + + .. note:: For formats where width of the array is not the same as the width of the image, + for example with yuv420p images the UV rows at the bottom have padding bytes in the middle of the + row as well as at the end. To cope with these, callers need to be able to pass the actual width. + """ + import numpy as np + + height = array.shape[0] + if not width: + width = array.shape[1] + + if format in {"rgb24", "bgr24"}: + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[2] == 3) + if array.strides[1:] != (3, 1): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"rgb48le", "rgb48be", "bgr48le", "bgr48be"}: + check_ndarray(array, "uint16", 3) + check_ndarray_shape(array, array.shape[2] == 3) + if array.strides[1:] != (6, 2): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"rgbf32le", "rgbf32be"}: + check_ndarray(array, "float32", 3) + check_ndarray_shape(array, array.shape[2] == 3) + if array.strides[1:] != (12, 4): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"rgba", "bgra", "argb", "abgr"}: + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (4, 1): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"rgba64le", "rgba64be", "bgra64le", "bgra64be"}: + check_ndarray(array, "uint16", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (8, 2): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"rgbaf16le", "rgbaf16be"}: + check_ndarray(array, "float16", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (8, 2): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"rgbaf32le", "rgbaf32be"}: + check_ndarray(array, "float32", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (16, 4): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in { + "gray", + "gray8", + "rgb8", + "bgr8", + "bayer_bggr8", + "bayer_gbrg8", + "bayer_grbg8", + "bayer_rggb8", + }: + check_ndarray(array, "uint8", 2) + if array.strides[1] != 1: + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in { + "gray9be", + "gray9le", + "gray10be", + "gray10le", + "gray12be", + "gray12le", + "gray14be", + "gray14le", + "gray16be", + "gray16le", + "bayer_bggr16be", + "bayer_bggr16le", + "bayer_gbrg16be", + "bayer_gbrg16le", + "bayer_grbg16be", + "bayer_grbg16le", + "bayer_rggb16be", + "bayer_rggb16le", + }: + check_ndarray(array, "uint16", 2) + if array.strides[1] != 2: + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"grayf32le", "grayf32be"}: + check_ndarray(array, "float32", 2) + if array.strides[1] != 4: + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = (array.strides[0],) + elif format in {"gbrp"}: + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[2] == 3) + if array.strides[1:] != (3, 1): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = ( + array.strides[0] // 3, + array.strides[0] // 3, + array.strides[0] // 3, + ) + elif format in { + "gbrp9be", + "gbrp9le", + "gbrp10be", + "gbrp10le", + "gbrp12be", + "gbrp12le", + "gbrp14be", + "gbrp14le", + "gbrp16be", + "gbrp16le", + }: + check_ndarray(array, "uint16", 3) + check_ndarray_shape(array, array.shape[2] == 3) + if array.strides[1:] != (6, 2): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = ( + array.strides[0] // 3, + array.strides[0] // 3, + array.strides[0] // 3, + ) + elif format in {"gbrpf32be", "gbrpf32le"}: + check_ndarray(array, "float32", 3) + check_ndarray_shape(array, array.shape[2] == 3) + if array.strides[1:] != (12, 4): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = ( + array.strides[0] // 3, + array.strides[0] // 3, + array.strides[0] // 3, + ) + elif format in {"gbrap"}: + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (4, 1): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = ( + array.strides[0] // 4, + array.strides[0] // 4, + array.strides[0] // 4, + array.strides[0] // 4, + ) + elif format in { + "gbrap10be", + "gbrap10le", + "gbrap12be", + "gbrap12le", + "gbrap14be", + "gbrap14le", + "gbrap16be", + "gbrap16le", + }: + check_ndarray(array, "uint16", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (8, 2): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = ( + array.strides[0] // 4, + array.strides[0] // 4, + array.strides[0] // 4, + array.strides[0] // 4, + ) + elif format in {"gbrapf32be", "gbrapf32le"}: + check_ndarray(array, "float32", 3) + check_ndarray_shape(array, array.shape[2] == 4) + if array.strides[1:] != (16, 4): + raise ValueError("provided array does not have C_CONTIGUOUS rows") + linesizes = ( + array.strides[0] // 4, + array.strides[0] // 4, + array.strides[0] // 4, + array.strides[0] // 4, + ) + elif format in {"yuv420p", "yuvj420p", "nv12"}: + check_ndarray(array, "uint8", 2) + check_ndarray_shape(array, array.shape[0] % 3 == 0) + check_ndarray_shape(array, array.shape[1] % 2 == 0) + height = height // 6 * 4 + if array.strides[1] != 1: + raise ValueError("provided array does not have C_CONTIGUOUS rows") + if format in {"yuv420p", "yuvj420p"}: + # For YUV420 planar formats, the UV plane stride is always half the Y stride. + linesizes = ( + array.strides[0], + array.strides[0] // 2, + array.strides[0] // 2, + ) + else: + # Planes where U and V are interleaved have the same stride as Y. + linesizes = (array.strides[0], array.strides[0]) + else: + raise ValueError( + f"Conversion from numpy array with format `{format}` is not yet supported" + ) + + if format.startswith("gbrap"): # rgba -> gbra + array = np.ascontiguousarray(np.moveaxis(array[..., [1, 2, 0, 3]], -1, 0)) + elif format.startswith("gbrp"): # rgb -> gbr + array = np.ascontiguousarray(np.moveaxis(array[..., [1, 2, 0]], -1, 0)) + + frame = VideoFrame(_cinit_bypass_sentinel) + frame._image_fill_pointers_numpy(array, width, height, linesizes, format) + return frame + + def _image_fill_pointers_numpy(self, buffer, width, height, linesizes, format): + # If you want to use the numpy notation, then you need to include the following lines at the top of the file: + # cimport numpy as cnp + # cnp.import_array() + + # And add the numpy include directories to the setup.py files + # hint np.get_include() + # cdef cnp.ndarray[ + # dtype=cnp.uint8_t, ndim=1, + # negative_indices=False, mode='c'] c_buffer + # c_buffer = buffer.reshape(-1) + # c_ptr = &c_buffer[0] + # c_ptr = ((buffer.ctypes.data)) + + # Using buffer.ctypes.data helps avoid any kind of usage of the c-api from + # numpy, which avoid the need to add numpy as a compile time dependency. + + c_data: cython.Py_ssize_t = buffer.ctypes.data + c_ptr: cython.pointer[uint8_t] = cython.cast(cython.pointer[uint8_t], c_data) + c_format: lib.AVPixelFormat = get_pix_fmt(format) + lib.av_frame_unref(self.ptr) + + # Hold on to a reference for the numpy buffer so that it doesn't get accidentally garbage collected + self.ptr.format = c_format + self.ptr.width = width + self.ptr.height = height + for i, linesize in enumerate(linesizes): + self.ptr.linesize[i] = linesize + + required = err_check( + lib.av_image_fill_pointers( + self.ptr.data, + cython.cast(lib.AVPixelFormat, self.ptr.format), + self.ptr.height, + c_ptr, + self.ptr.linesize, + ) + ) + + py_buf = cython.cast(object, buffer) + Py_INCREF(py_buf) + + self.ptr.buf[0] = lib.av_buffer_create( + c_ptr, + required, + _numpy_avbuffer_free, + cython.cast(cython.p_void, py_buf), + 0, + ) + if self.ptr.buf[0] == cython.NULL: + Py_DECREF(py_buf) + raise MemoryError("av_buffer_create failed") + + self._init_user_attributes() + + @staticmethod + @cython.cdivision(True) + def from_ndarray(array, format="rgb24", channel_last=False): + """ + Construct a frame from a numpy array. + + :param bool channel_last: If False (default), the shape for the yuv444p and yuvj444p + is given by (channels, height, width) rather than (height, width, channels). + + .. note:: For formats which expect an array of ``uint16``, ``float16`` or ``float32``, + the samples must be in the system's native byte order. + + .. note:: for ``pal8``, an ``(image, palette)`` pair must be passed. `palette` must + have shape (256, 4) and is given in ARGB format (PyAV will swap bytes if needed). + + .. note:: for ``gbrp`` formats, channels are assumed to be given in RGB order. + + """ + import numpy as np + + # case layers are concatenated + channels, itemsize, dtype = { + "bayer_bggr16be": (1, 2, "uint16"), + "bayer_bggr16le": (1, 2, "uint16"), + "bayer_bggr8": (1, 1, "uint8"), + "bayer_gbrg16be": (1, 2, "uint16"), + "bayer_gbrg16le": (1, 2, "uint16"), + "bayer_gbrg8": (1, 1, "uint8"), + "bayer_grbg16be": (1, 2, "uint16"), + "bayer_grbg16le": (1, 2, "uint16"), + "bayer_grbg8": (1, 1, "uint8"), + "bayer_rggb16be": (1, 2, "uint16"), + "bayer_rggb16le": (1, 2, "uint16"), + "bayer_rggb8": (1, 1, "uint8"), + "bgr8": (1, 1, "uint8"), + "gbrap": (4, 1, "uint8"), + "gbrap10be": (4, 2, "uint16"), + "gbrap10le": (4, 2, "uint16"), + "gbrap12be": (4, 2, "uint16"), + "gbrap12le": (4, 2, "uint16"), + "gbrap14be": (4, 2, "uint16"), + "gbrap14le": (4, 2, "uint16"), + "gbrap16be": (4, 2, "uint16"), + "gbrap16le": (4, 2, "uint16"), + "gbrapf32be": (4, 4, "float32"), + "gbrapf32le": (4, 4, "float32"), + "gbrp": (3, 1, "uint8"), + "gbrp10be": (3, 2, "uint16"), + "gbrp10le": (3, 2, "uint16"), + "gbrp12be": (3, 2, "uint16"), + "gbrp12le": (3, 2, "uint16"), + "gbrp14be": (3, 2, "uint16"), + "gbrp14le": (3, 2, "uint16"), + "gbrp16be": (3, 2, "uint16"), + "gbrp16le": (3, 2, "uint16"), + "gbrp9be": (3, 2, "uint16"), + "gbrp9le": (3, 2, "uint16"), + "gbrpf32be": (3, 4, "float32"), + "gbrpf32le": (3, 4, "float32"), + "gray": (1, 1, "uint8"), + "gray10be": (1, 2, "uint16"), + "gray10le": (1, 2, "uint16"), + "gray12be": (1, 2, "uint16"), + "gray12le": (1, 2, "uint16"), + "gray14be": (1, 2, "uint16"), + "gray14le": (1, 2, "uint16"), + "gray16be": (1, 2, "uint16"), + "gray16le": (1, 2, "uint16"), + "gray8": (1, 1, "uint8"), + "gray9be": (1, 2, "uint16"), + "gray9le": (1, 2, "uint16"), + "grayf32be": (1, 4, "float32"), + "grayf32le": (1, 4, "float32"), + "rgb8": (1, 1, "uint8"), + "yuv444p": (3, 1, "uint8"), + "yuv444p16be": (3, 2, "uint16"), + "yuv444p16le": (3, 2, "uint16"), + "yuva444p16be": (4, 2, "uint16"), + "yuva444p16le": (4, 2, "uint16"), + "yuvj444p": (3, 1, "uint8"), + }.get(format, (None, None, None)) + if channels is not None: + if array.ndim == 2: # (height, width) -> (height, width, 1) + array = array[:, :, None] + check_ndarray(array, dtype, 3) + if not channel_last and format in {"yuv444p", "yuvj444p"}: + array = np.moveaxis(array, 0, 2) # (channels, h, w) -> (h, w, channels) + check_ndarray_shape(array, array.shape[2] == channels) + array = byteswap_array(array, format.endswith("be")) + frame = VideoFrame(array.shape[1], array.shape[0], format) + if frame.format.name.startswith("gbr"): # rgb -> gbr + array = np.concatenate( + [ # not inplace to avoid bad surprises + array[:, :, 1:3], + array[:, :, 0:1], + array[:, :, 3:], + ], + axis=2, + ) + for i in range(channels): + copy_array_to_plane(array[:, :, i], frame.planes[i], itemsize) + return frame + + # other cases + if format == "pal8": + array, palette = array + check_ndarray(array, "uint8", 2) + check_ndarray(palette, "uint8", 2) + check_ndarray_shape(palette, palette.shape == (256, 4)) + + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane(array, frame.planes[0], 1) + frame.planes[1].update(palette.view(">i4").astype("i4").tobytes()) + return frame + elif format in {"yuv420p", "yuvj420p"}: + check_ndarray(array, "uint8", 2) + check_ndarray_shape(array, array.shape[0] % 3 == 0) + check_ndarray_shape(array, array.shape[1] % 2 == 0) + + frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format) + if frame.width == 0 or frame.height == 0: + return frame + u_start = frame.width * frame.height + v_start = 5 * u_start // 4 + flat = array.reshape(-1) + copy_array_to_plane(flat[0:u_start], frame.planes[0], 1) + copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 1) + copy_array_to_plane(flat[v_start:], frame.planes[2], 1) + return frame + elif format == "yuv420p10le": + check_ndarray(array, "uint16", 2) + check_ndarray_shape(array, array.shape[0] % 3 == 0) + check_ndarray_shape(array, array.shape[1] % 2 == 0) + + frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format) + if frame.width == 0 or frame.height == 0: + return frame + u_start = frame.width * frame.height + v_start = 5 * u_start // 4 + flat = array.reshape(-1) + copy_array_to_plane(flat[0:u_start], frame.planes[0], 2) + copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 2) + copy_array_to_plane(flat[v_start:], frame.planes[2], 2) + return frame + elif format == "yuv422p": + check_ndarray(array, "uint8", 2) + check_ndarray_shape(array, array.shape[0] % 4 == 0) + check_ndarray_shape(array, array.shape[1] % 2 == 0) + + frame = VideoFrame(array.shape[1], array.shape[0] // 2, format) + if frame.width == 0 or frame.height == 0: + return frame + u_start = frame.width * frame.height + v_start = u_start + u_start // 2 + flat = array.reshape(-1) + copy_array_to_plane(flat[0:u_start], frame.planes[0], 1) + copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 1) + copy_array_to_plane(flat[v_start:], frame.planes[2], 1) + return frame + elif format == "yuv422p10le": + if not isinstance(array, np.ndarray) or array.dtype != np.uint16: + raise ValueError("Array must be uint16 type") + + # Convert to channel-first if needed + if channel_last and array.shape[2] == 3: + array = np.moveaxis(array, 2, 0) + elif not (array.shape[0] == 3): + raise ValueError( + "Array must have shape (3, height, width) or (height, width, 3)" + ) + + height, width = array.shape[1:] + if width % 2 != 0 or height % 2 != 0: + raise ValueError("Width and height must be even") + + frame = VideoFrame(width, height, format) + copy_array_to_plane(array[0], frame.planes[0], 2) + # Subsample U and V by taking every other column + u = array[1, :, ::2].copy() # Need copy to ensure C-contiguous + v = array[2, :, ::2].copy() # Need copy to ensure C-contiguous + copy_array_to_plane(u, frame.planes[1], 2) + copy_array_to_plane(v, frame.planes[2], 2) + return frame + elif format == "yuyv422": + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[0] % 2 == 0) + check_ndarray_shape(array, array.shape[1] % 2 == 0) + check_ndarray_shape(array, array.shape[2] == 2) + elif format in {"rgb24", "bgr24"}: + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[2] == 3) + elif format in {"argb", "rgba", "abgr", "bgra"}: + check_ndarray(array, "uint8", 3) + check_ndarray_shape(array, array.shape[2] == 4) + elif format in {"rgb48be", "rgb48le", "bgr48be", "bgr48le"}: + check_ndarray(array, "uint16", 3) + check_ndarray_shape(array, array.shape[2] == 3) + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane( + byteswap_array(array, format.endswith("be")), frame.planes[0], 6 + ) + return frame + elif format in {"rgbf32be", "rgbf32le"}: + check_ndarray(array, "float32", 3) + check_ndarray_shape(array, array.shape[2] == 3) + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane( + byteswap_array(array, format.endswith("be")), frame.planes[0], 12 + ) + return frame + elif format in {"rgba64be", "rgba64le", "bgra64be", "bgra64le"}: + check_ndarray(array, "uint16", 3) + check_ndarray_shape(array, array.shape[2] == 4) + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane( + byteswap_array(array, format.endswith("be")), frame.planes[0], 8 + ) + return frame + elif format in {"rgbaf16be", "rgbaf16le"}: + check_ndarray(array, "float16", 3) + check_ndarray_shape(array, array.shape[2] == 4) + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane( + byteswap_array(array, format.endswith("be")), frame.planes[0], 8 + ) + return frame + elif format in {"rgbaf32be", "rgbaf32le"}: + check_ndarray(array, "float32", 3) + check_ndarray_shape(array, array.shape[2] == 4) + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane( + byteswap_array(array, format.endswith("be")), frame.planes[0], 16 + ) + return frame + elif format == "nv12": + check_ndarray(array, "uint8", 2) + check_ndarray_shape(array, array.shape[0] % 3 == 0) + check_ndarray_shape(array, array.shape[1] % 2 == 0) + + frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format) + uv_start = frame.width * frame.height + flat = array.reshape(-1) + copy_array_to_plane(flat[:uv_start], frame.planes[0], 1) + copy_array_to_plane(flat[uv_start:], frame.planes[1], 2) + return frame + else: + raise ValueError( + f"Conversion from numpy array with format `{format}` is not yet supported" + ) + + frame = VideoFrame(array.shape[1], array.shape[0], format) + copy_array_to_plane( + array, frame.planes[0], 1 if array.ndim == 2 else array.shape[2] + ) + + return frame + + @staticmethod + def from_bytes( + img_bytes: bytes, + width: int, + height: int, + format="rgba", + flip_horizontal=False, + flip_vertical=False, + ): + frame = VideoFrame(width, height, format) + if format == "rgba": + copy_bytes_to_plane( + img_bytes, frame.planes[0], 4, flip_horizontal, flip_vertical + ) + elif format in { + "bayer_bggr8", + "bayer_rggb8", + "bayer_gbrg8", + "bayer_grbg8", + "bayer_bggr16le", + "bayer_rggb16le", + "bayer_gbrg16le", + "bayer_grbg16le", + "bayer_bggr16be", + "bayer_rggb16be", + "bayer_gbrg16be", + "bayer_grbg16be", + }: + copy_bytes_to_plane( + img_bytes, + frame.planes[0], + 1 if format.endswith("8") else 2, + flip_horizontal, + flip_vertical, + ) + else: + raise NotImplementedError(f"Format '{format}' is not supported.") + return frame + + @staticmethod + def from_dlpack( + planes, + format: str = "nv12", + width: int = 0, + height: int = 0, + stream=None, + device_id: int | None = None, + primary_ctx: bool | None = None, + cuda_context=None, + current_ctx: bool | None = None, + ): + if not isinstance(planes, (tuple, list)): + planes = (planes,) + + sw_fmt: lib.AVPixelFormat = get_pix_fmt(format) + nv12 = get_pix_fmt(b"nv12") + p010le = get_pix_fmt(b"p010le") + p016le = get_pix_fmt(b"p016le") + + if sw_fmt not in (nv12, p010le, p016le): + return VideoFrame._from_dlpack_planar( + planes, sw_fmt, format, width, height, stream, device_id + ) + + if len(planes) != 2: + raise ValueError( + "from_dlpack currently supports 2-plane formats only (nv12/p010le/p016le)" + ) + + expected_bits = 8 if sw_fmt == nv12 else 16 + itemsize = 1 if expected_bits == 8 else 2 + + m0: cython.pointer[DLManagedTensor] = cython.NULL + m1: cython.pointer[DLManagedTensor] = cython.NULL + frame: VideoFrame = None + + try: + m0 = _consume_dlpack(planes[0], stream) + m1 = _consume_dlpack(planes[1], stream) + + dev_type0 = m0.dl_tensor.device_type + dev_type1 = m1.dl_tensor.device_type + if dev_type0 != dev_type1: + raise ValueError("plane tensors must have the same device_type") + if dev_type0 not in (kCuda, kCPU): + raise NotImplementedError( + "only CPU and CUDA DLPack tensors are supported" + ) + + dev0 = m0.dl_tensor.device_id + dev1 = m1.dl_tensor.device_id + if dev0 != dev1: + raise ValueError("plane tensors must be on the same CUDA device") + if dev_type0 == kCuda: + if device_id is None: + device_id = dev0 + elif device_id != dev0: + raise ValueError( + "device_id does not match the DLPack tensor device_id" + ) + else: + if device_id not in (None, 0): + raise ValueError("device_id must be 0 for CPU tensors") + device_id = 0 + if dev_type0 == kCPU and (dev0 != 0 or dev1 != 0): + raise ValueError("CPU DLPack tensors must have device_id == 0") + + if ( + m0.dl_tensor.dtype.code != 1 + or m0.dl_tensor.dtype.bits != expected_bits + or m0.dl_tensor.dtype.lanes != 1 + ): + raise TypeError("unexpected dtype for plane 0") + if ( + m1.dl_tensor.dtype.code != 1 + or m1.dl_tensor.dtype.bits != expected_bits + or m1.dl_tensor.dtype.lanes != 1 + ): + raise TypeError("unexpected dtype for plane 1") + + if m0.dl_tensor.ndim != 2: + raise ValueError("plane 0 must be 2D (H, W)") + + y_h = cython.cast(int64_t, m0.dl_tensor.shape[0]) + y_w = cython.cast(int64_t, m0.dl_tensor.shape[1]) + + if width == 0 and height == 0: + width = cython.cast(int, y_w) + height = cython.cast(int, y_h) + elif width == 0 or height == 0: + raise ValueError("either specify both width/height or neither") + else: + if y_w != width or y_h != height: + raise ValueError("plane 0 shape does not match width/height") + + if width % 2 or height % 2: + raise ValueError("width/height must be even for nv12/p010le/p016le") + + if m0.dl_tensor.strides != cython.NULL: + if m0.dl_tensor.strides[1] != 1: + raise ValueError("plane 0 must be contiguous in the last dimension") + y_pitch_elems = cython.cast(int64_t, m0.dl_tensor.strides[0]) + else: + y_pitch_elems = cython.cast(int64_t, width) + + y_linesize = cython.cast(int, y_pitch_elems * itemsize) + y_size = cython.cast(int, y_linesize * height) + + uv_ndim = m1.dl_tensor.ndim + uv_h_expected = height // 2 + + if uv_ndim == 2: + uv_h = cython.cast(int, m1.dl_tensor.shape[0]) + uv_w = cython.cast(int, m1.dl_tensor.shape[1]) + if uv_h != uv_h_expected or uv_w != width: + raise ValueError("plane 1 must have shape (H/2, W) for 2D UV") + if m1.dl_tensor.strides != cython.NULL: + if m1.dl_tensor.strides[1] != 1: + raise ValueError( + "plane 1 must be contiguous in the last dimension" + ) + uv_pitch_elems = cython.cast(int64_t, m1.dl_tensor.strides[0]) + else: + uv_pitch_elems = cython.cast(int64_t, uv_w) + elif uv_ndim == 3: + uv_h = cython.cast(int, m1.dl_tensor.shape[0]) + uv_w2 = cython.cast(int, m1.dl_tensor.shape[1]) + uv_c = cython.cast(int, m1.dl_tensor.shape[2]) + if uv_h != uv_h_expected or uv_w2 != (width // 2) or uv_c != 2: + raise ValueError("plane 1 must have shape (H/2, W/2, 2) for 3D UV") + if m1.dl_tensor.strides != cython.NULL: + if m1.dl_tensor.strides[2] != 1 or m1.dl_tensor.strides[1] != 2: + raise ValueError( + "unexpected UV plane strides for (H/2, W/2, 2)" + ) + uv_pitch_elems = cython.cast(int64_t, m1.dl_tensor.strides[0]) + else: + uv_pitch_elems = cython.cast(int64_t, width) + else: + raise ValueError("plane 1 must be 2D or 3D") + + uv_linesize = cython.cast(int, uv_pitch_elems * itemsize) + uv_size = cython.cast(int, uv_linesize * (height // 2)) + + frame = alloc_video_frame() + frame.ptr.width = width + frame.ptr.height = height + if dev_type0 == kCuda: + ctx: CudaContext + frames_ref: cython.pointer[lib.AVBufferRef] + if cuda_context is None: + ctx = CudaContext( + device_id=device_id, + primary_ctx=( + not current_ctx if primary_ctx is None else primary_ctx + ), + current_ctx=bool(current_ctx), + ) + else: + if not isinstance(cuda_context, CudaContext): + raise TypeError("cuda_context must be a CudaContext") + if int(cuda_context.device_id) != int(device_id): + raise ValueError( + "cuda_context.device_id does not match the DLPack tensor device_id" + ) + if primary_ctx is not None and bool( + cuda_context.primary_ctx + ) != bool(primary_ctx): + raise ValueError( + "cuda_context.primary_ctx does not match primary_ctx" + ) + if current_ctx is not None and bool( + cuda_context.current_ctx + ) != bool(current_ctx): + raise ValueError( + "cuda_context.current_ctx does not match current_ctx" + ) + ctx = cython.cast(CudaContext, cuda_context) + + frames_ref = ctx.get_frames_ctx(sw_fmt, width, height) + frame.ptr.format = get_pix_fmt(b"cuda") + frame.ptr.hw_frames_ctx = frames_ref + frame._device_id = device_id + frame._cuda_ctx = ctx + else: + frame.ptr.format = sw_fmt + + y_ptr = cython.cast( + cython.pointer[uint8_t], m0.dl_tensor.data + ) + cython.cast(cython.size_t, m0.dl_tensor.byte_offset) + uv_ptr = cython.cast( + cython.pointer[uint8_t], m1.dl_tensor.data + ) + cython.cast(cython.size_t, m1.dl_tensor.byte_offset) + + frame.ptr.buf[0] = lib.av_buffer_create( + y_ptr, y_size, _dlpack_avbuffer_free, cython.cast(cython.p_void, m0), 0 + ) + if frame.ptr.buf[0] == cython.NULL: + raise MemoryError("av_buffer_create failed for plane 0") + frame.ptr.data[0] = y_ptr + frame.ptr.linesize[0] = y_linesize + m0 = cython.NULL + + frame.ptr.buf[1] = lib.av_buffer_create( + uv_ptr, + uv_size, + _dlpack_avbuffer_free, + cython.cast(cython.p_void, m1), + 0, + ) + if frame.ptr.buf[1] == cython.NULL: + raise MemoryError("av_buffer_create failed for plane 1") + frame.ptr.data[1] = uv_ptr + frame.ptr.linesize[1] = uv_linesize + m1 = cython.NULL + + frame._init_user_attributes() + return frame + + except Exception: + if frame is not None: + lib.av_frame_unref(frame.ptr) + if m0 != cython.NULL: + m0.deleter(m0) + if m1 != cython.NULL: + m1.deleter(m1) + raise + + @staticmethod + def _from_dlpack_planar(planes, sw_fmt, format, width, height, stream, device_id): + # CPU-only import for planar formats whose planes each hold a single + # component: yuv420p/yuv422p/yuv444p, gray, gbrp, and their 16-bit + # little-endian variants. nv12/p010le/p016le keep their dedicated + # 2-plane path in from_dlpack(). + desc: cython.pointer[cython.const[lib.AVPixFmtDescriptor]] = ( + lib.av_pix_fmt_desc_get(sw_fmt) + ) + if desc == cython.NULL: + raise NotImplementedError(f"unknown pixel format {format!r}") + if desc.flags & ( + lib.AV_PIX_FMT_FLAG_BITSTREAM + | lib.AV_PIX_FMT_FLAG_PAL + | lib.AV_PIX_FMT_FLAG_BAYER + ): + raise NotImplementedError( + f"from_dlpack does not support bitstream, palette, or Bayer " + f"formats ({format!r})" + ) + + i: cython.int + nb_planes: cython.int = 0 + for i in range(desc.nb_components): + if cython.cast(cython.int, desc.comp[i].plane) + 1 > nb_planes: + nb_planes = desc.comp[i].plane + 1 + + p: cython.int + count: cython.int + comp_of_plane = [0] * nb_planes + for p in range(nb_planes): + count = 0 + for i in range(desc.nb_components): + if cython.cast(cython.int, desc.comp[i].plane) == p: + comp_of_plane[p] = i + count += 1 + if count != 1: + raise NotImplementedError( + "from_dlpack supports nv12/p010le/p016le and planar " + f"single-component formats; {format!r} is not supported" + ) + + if len(planes) != nb_planes: + raise ValueError( + f"{format!r} requires {nb_planes} plane(s), got {len(planes)}" + ) + + itemsize: cython.int = desc.comp[0].step + if itemsize != 1 and itemsize != 2: + raise NotImplementedError( + "only 8- and 16-bit components are supported for DLPack import" + ) + if itemsize == 2 and desc.flags & lib.AV_PIX_FMT_FLAG_BE: + raise NotImplementedError( + "big-endian formats are not supported for DLPack import" + ) + expected_bits: cython.int = itemsize * 8 + + if device_id not in (None, 0): + raise ValueError("device_id must be 0 for CPU tensors") + + log2_w: cython.int = desc.log2_chroma_w + log2_h: cython.int = desc.log2_chroma_h + + frame: VideoFrame = None + m: cython.pointer[DLManagedTensor] = cython.NULL + try: + frame = alloc_video_frame() + frame.ptr.format = sw_fmt + + for p in range(nb_planes): + m = _consume_dlpack(planes[p], stream) + + if m.dl_tensor.device_type != kCPU: + raise NotImplementedError( + "only CPU DLPack tensors are supported for this format" + ) + if m.dl_tensor.device_id != 0: + raise ValueError("CPU DLPack tensors must have device_id == 0") + + if ( + m.dl_tensor.dtype.code != 1 + or m.dl_tensor.dtype.bits != expected_bits + or m.dl_tensor.dtype.lanes != 1 + ): + raise TypeError(f"unexpected dtype for plane {p}") + + if m.dl_tensor.ndim != 2: + raise ValueError(f"plane {p} must be 2D (H, W)") + + ph = cython.cast(int64_t, m.dl_tensor.shape[0]) + pw = cython.cast(int64_t, m.dl_tensor.shape[1]) + + if p == 0: + if width == 0 and height == 0: + width = cython.cast(int, pw) + height = cython.cast(int, ph) + elif width == 0 or height == 0: + raise ValueError("either specify both width/height or neither") + elif pw != width or ph != height: + raise ValueError("plane 0 shape does not match width/height") + if (log2_w and width % 2) or (log2_h and height % 2): + raise ValueError(f"width/height must be even for {format!r}") + frame.ptr.width = width + frame.ptr.height = height + + comp_idx = comp_of_plane[p] + is_chroma = (comp_idx == 1 or comp_idx == 2) and (log2_w or log2_h) + exp_w = (-((-width) >> log2_w)) if is_chroma else width + exp_h = (-((-height) >> log2_h)) if is_chroma else height + + if pw != exp_w or ph != exp_h: + raise ValueError(f"plane {p} must have shape ({exp_h}, {exp_w})") + + if m.dl_tensor.strides != cython.NULL: + if m.dl_tensor.strides[1] != 1: + raise ValueError( + f"plane {p} must be contiguous in the last dimension" + ) + pitch_elems = cython.cast(int64_t, m.dl_tensor.strides[0]) + else: + pitch_elems = cython.cast(int64_t, exp_w) + + linesize = cython.cast(int, pitch_elems * itemsize) + size = cython.cast(int, linesize * exp_h) + + ptr = cython.cast( + cython.pointer[uint8_t], m.dl_tensor.data + ) + cython.cast(cython.size_t, m.dl_tensor.byte_offset) + + frame.ptr.buf[p] = lib.av_buffer_create( + ptr, size, _dlpack_avbuffer_free, cython.cast(cython.p_void, m), 0 + ) + if frame.ptr.buf[p] == cython.NULL: + raise MemoryError(f"av_buffer_create failed for plane {p}") + frame.ptr.data[p] = ptr + frame.ptr.linesize[p] = linesize + m = cython.NULL + + frame._init_user_attributes() + return frame + + except Exception: + if frame is not None: + lib.av_frame_unref(frame.ptr) + if m != cython.NULL: + m.deleter(m) + raise diff --git a/av/video/frame.pyi b/av/video/frame.pyi new file mode 100644 index 000000000..f07a8079f --- /dev/null +++ b/av/video/frame.pyi @@ -0,0 +1,123 @@ +from enum import IntEnum +from pathlib import Path +from typing import Any + +import numpy as np + +from av.frame import Frame + +from .format import VideoFormat +from .plane import VideoPlane +from .reformatter import ColorPrimaries, ColorTrc + +_SupportedNDarray = ( + np.ndarray[Any, np.dtype[np.uint8]] + | np.ndarray[Any, np.dtype[np.uint16]] + | np.ndarray[Any, np.dtype[np.float16]] + | np.ndarray[Any, np.dtype[np.float32]] +) + +supported_np_pix_fmts: set[str] + +class PictureType(IntEnum): + NONE = 0 + I = 1 + P = 2 + B = 3 + S = 4 + SI = 5 + SP = 6 + BI = 7 + +class CudaContext: + @property + def device_id(self) -> int: ... + @property + def primary_ctx(self) -> bool: ... + @property + def current_ctx(self) -> bool: ... + @property + def cuda_stream(self) -> int: ... + def __init__( + self, + device_id: int = 0, + primary_ctx: bool = True, + current_ctx: bool = False, + cuda_stream: int | None = None, + ) -> None: ... + +class VideoFrame(Frame): + format: VideoFormat + planes: tuple[VideoPlane, ...] + pict_type: int + colorspace: int + color_range: int + color_trc: int + color_primaries: int + + @property + def sw_format(self) -> VideoFormat | None: ... + @property + def time(self) -> float: ... + @property + def width(self) -> int: ... + @property + def height(self) -> int: ... + @property + def interlaced_frame(self) -> bool: ... + @property + def rotation(self) -> int: ... + def __init__( + self, width: int = 0, height: int = 0, format: str = "yuv420p" + ) -> None: ... + def reformat( + self, + width: int | None = None, + height: int | None = None, + format: str | None = None, + src_colorspace: str | int | None = None, + dst_colorspace: str | int | None = None, + interpolation: int | str | None = None, + src_color_range: int | str | None = None, + dst_color_range: int | str | None = None, + dst_color_trc: int | ColorTrc | None = None, + dst_color_primaries: int | ColorPrimaries | None = None, + threads: int | None = None, + ) -> VideoFrame: ... + def to_rgb(self, **kwargs: Any) -> VideoFrame: ... + def save(self, filepath: str | Path) -> None: ... + def to_image(self, **kwargs): ... + def to_ndarray( + self, channel_last: bool = False, **kwargs: Any + ) -> _SupportedNDarray: ... + @staticmethod + def from_image(img): ... + @staticmethod + def from_numpy_buffer( + array: _SupportedNDarray, format: str = "rgb24", width: int = 0 + ) -> VideoFrame: ... + @staticmethod + def from_ndarray( + array: _SupportedNDarray, format: str = "rgb24", channel_last: bool = False + ) -> VideoFrame: ... + @staticmethod + def from_bytes( + data: bytes, + width: int, + height: int, + format: str = "rgba", + flip_horizontal: bool = False, + flip_vertical: bool = False, + ) -> VideoFrame: ... + @staticmethod + def from_dlpack( + planes: object | tuple[object, ...], + format: str = "nv12", + width: int = 0, + height: int = 0, + stream: int | None = None, + device_id: int | None = None, + primary_ctx: bool | None = None, + cuda_context: CudaContext | None = None, + current_ctx: bool | None = None, + ) -> VideoFrame: ... diff --git a/av/video/frame.pyx b/av/video/frame.pyx deleted file mode 100644 index 62601ce0e..000000000 --- a/av/video/frame.pyx +++ /dev/null @@ -1,351 +0,0 @@ -from libc.stdint cimport uint8_t - -from av.enum cimport define_enum -from av.error cimport err_check -from av.video.format cimport VideoFormat, get_video_format -from av.video.plane cimport VideoPlane - -from av.deprecation import renamed_attr - - -cdef object _cinit_bypass_sentinel - -cdef VideoFrame alloc_video_frame(): - """Get a mostly uninitialized VideoFrame. - - You MUST call VideoFrame._init(...) or VideoFrame._init_user_attributes() - before exposing to the user. - - """ - return VideoFrame.__new__(VideoFrame, _cinit_bypass_sentinel) - - -PictureType = define_enum('PictureType', __name__, ( - ('NONE', lib.AV_PICTURE_TYPE_NONE, "Undefined"), - ('I', lib.AV_PICTURE_TYPE_I, "Intra"), - ('P', lib.AV_PICTURE_TYPE_P, "Predicted"), - ('B', lib.AV_PICTURE_TYPE_B, "Bi-directional predicted"), - ('S', lib.AV_PICTURE_TYPE_S, "S(GMC)-VOP MPEG-4"), - ('SI', lib.AV_PICTURE_TYPE_SI, "Switching intra"), - ('SP', lib.AV_PICTURE_TYPE_SP, "Switching predicted"), - ('BI', lib.AV_PICTURE_TYPE_BI, "BI type"), -)) - - -cdef copy_array_to_plane(array, VideoPlane plane, unsigned int bytes_per_pixel): - cdef bytes imgbytes = array.tobytes() - cdef const uint8_t[:] i_buf = imgbytes - cdef size_t i_pos = 0 - cdef size_t i_stride = plane.width * bytes_per_pixel - cdef size_t i_size = plane.height * i_stride - - cdef uint8_t[:] o_buf = plane - cdef size_t o_pos = 0 - cdef size_t o_stride = abs(plane.line_size) - - while i_pos < i_size: - o_buf[o_pos:o_pos + i_stride] = i_buf[i_pos:i_pos + i_stride] - i_pos += i_stride - o_pos += o_stride - - -cdef useful_array(VideoPlane plane, unsigned int bytes_per_pixel=1): - """ - Return the useful part of the VideoPlane as a single dimensional array. - - We are simply discarding any padding which was added for alignment. - """ - import numpy as np - cdef size_t total_line_size = abs(plane.line_size) - cdef size_t useful_line_size = plane.width * bytes_per_pixel - arr = np.frombuffer(plane, np.uint8) - if total_line_size != useful_line_size: - arr = arr.reshape(-1, total_line_size)[:, 0:useful_line_size].reshape(-1) - return arr - - -cdef class VideoFrame(Frame): - - def __cinit__(self, width=0, height=0, format='yuv420p'): - - if width is _cinit_bypass_sentinel: - return - - cdef lib.AVPixelFormat c_format = lib.av_get_pix_fmt(format) - if c_format < 0: - raise ValueError('invalid format %r' % format) - - self._init(c_format, width, height) - - cdef _init(self, lib.AVPixelFormat format, unsigned int width, unsigned int height): - - cdef int res = 0 - - with nogil: - self.ptr.width = width - self.ptr.height = height - self.ptr.format = format - - # Allocate the buffer for the video frame. - # - # We enforce aligned buffers, otherwise `sws_scale` can perform - # poorly or even cause out-of-bounds reads and writes. - if width and height: - res = lib.av_image_alloc( - self.ptr.data, - self.ptr.linesize, - width, - height, - format, - 16) - self._buffer = self.ptr.data[0] - - if res: - err_check(res) - - self._init_user_attributes() - - cdef _init_user_attributes(self): - self.format = get_video_format(self.ptr.format, self.ptr.width, self.ptr.height) - - def __dealloc__(self): - # The `self._buffer` member is only set if *we* allocated the buffer in `_init`, - # as opposed to a buffer allocated by a decoder. - lib.av_freep(&self._buffer) - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.index, - self.pts, - self.format.name, - self.width, - self.height, - id(self), - ) - - @property - def planes(self): - """ - A tuple of :class:`.VideoPlane` objects. - """ - # We need to detect which planes actually exist, but also contrain - # ourselves to the maximum plane count (as determined only by VideoFrames - # so far), in case the library implementation does not set the last - # plane to NULL. - cdef int max_plane_count = 0 - for i in range(self.format.ptr.nb_components): - count = self.format.ptr.comp[i].plane + 1 - if max_plane_count < count: - max_plane_count = count - if self.format.name == 'pal8': - max_plane_count = 2 - - cdef int plane_count = 0 - while plane_count < max_plane_count and self.ptr.extended_data[plane_count]: - plane_count += 1 - - return tuple([VideoPlane(self, i) for i in range(plane_count)]) - - property width: - """Width of the image, in pixels.""" - def __get__(self): return self.ptr.width - - property height: - """Height of the image, in pixels.""" - def __get__(self): return self.ptr.height - - property key_frame: - """Is this frame a key frame? - - Wraps :ffmpeg:`AVFrame.key_frame`. - - """ - def __get__(self): return self.ptr.key_frame - - property interlaced_frame: - """Is this frame an interlaced or progressive? - - Wraps :ffmpeg:`AVFrame.interlaced_frame`. - - """ - def __get__(self): return self.ptr.interlaced_frame - - @property - def pict_type(self): - """One of :class:`.PictureType`. - - Wraps :ffmpeg:`AVFrame.pict_type`. - - """ - return PictureType.get(self.ptr.pict_type, create=True) - - @pict_type.setter - def pict_type(self, value): - self.ptr.pict_type = PictureType[value].value - - def reformat(self, *args, **kwargs): - """reformat(width=None, height=None, format=None, src_colorspace=None, dst_colorspace=None, interpolation=None) - - Create a new :class:`VideoFrame` with the given width/height/format/colorspace. - - .. seealso:: :meth:`.VideoReformatter.reformat` for arguments. - - """ - if not self.reformatter: - self.reformatter = VideoReformatter() - return self.reformatter.reformat(self, *args, **kwargs) - - def to_rgb(self, **kwargs): - """Get an RGB version of this frame. - - Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`. - - >>> frame = VideoFrame(1920, 1080) - >>> frame.format.name - 'yuv420p' - >>> frame.to_rgb().format.name - 'rgb24' - - """ - return self.reformat(format="rgb24", **kwargs) - - def to_image(self, **kwargs): - """Get an RGB ``PIL.Image`` of this frame. - - Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`. - - .. note:: PIL or Pillow must be installed. - - """ - from PIL import Image - cdef VideoPlane plane = self.reformat(format="rgb24", **kwargs).planes[0] - - cdef const uint8_t[:] i_buf = plane - cdef size_t i_pos = 0 - cdef size_t i_stride = plane.line_size - - cdef size_t o_pos = 0 - cdef size_t o_stride = plane.width * 3 - cdef size_t o_size = plane.height * o_stride - cdef bytearray o_buf = bytearray(o_size) - - while o_pos < o_size: - o_buf[o_pos:o_pos + o_stride] = i_buf[i_pos:i_pos + o_stride] - i_pos += i_stride - o_pos += o_stride - - return Image.frombytes("RGB", (self.width, self.height), bytes(o_buf), "raw", "RGB", 0, 1) - - def to_ndarray(self, **kwargs): - """Get a numpy array of this frame. - - Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`. - - .. note:: Numpy must be installed. - - .. note:: For ``pal8``, an ``(image, palette)`` tuple will be returned, - with the palette being in ARGB (PyAV will swap bytes if needed). - - """ - cdef VideoFrame frame = self.reformat(**kwargs) - - import numpy as np - - if frame.format.name in ('yuv420p', 'yuvj420p'): - assert frame.width % 2 == 0 - assert frame.height % 2 == 0 - return np.hstack(( - useful_array(frame.planes[0]), - useful_array(frame.planes[1]), - useful_array(frame.planes[2]) - )).reshape(-1, frame.width) - elif frame.format.name == 'yuyv422': - assert frame.width % 2 == 0 - assert frame.height % 2 == 0 - return useful_array(frame.planes[0], 2).reshape(frame.height, frame.width, -1) - elif frame.format.name in ('rgb24', 'bgr24'): - return useful_array(frame.planes[0], 3).reshape(frame.height, frame.width, -1) - elif frame.format.name in ('argb', 'rgba', 'abgr', 'bgra'): - return useful_array(frame.planes[0], 4).reshape(frame.height, frame.width, -1) - elif frame.format.name in ('gray', 'gray8', 'rgb8', 'bgr8'): - return useful_array(frame.planes[0]).reshape(frame.height, frame.width) - elif frame.format.name == 'pal8': - image = useful_array(frame.planes[0]).reshape(frame.height, frame.width) - palette = np.frombuffer(frame.planes[1], 'i4').astype('>i4').reshape(-1, 1).view(np.uint8) - return image, palette - else: - raise ValueError('Conversion to numpy array with format `%s` is not yet supported' % frame.format.name) - - to_nd_array = renamed_attr('to_ndarray') - - @staticmethod - def from_image(img): - """ - Construct a frame from a ``PIL.Image``. - """ - if img.mode != 'RGB': - img = img.convert('RGB') - - cdef VideoFrame frame = VideoFrame(img.size[0], img.size[1], 'rgb24') - copy_array_to_plane(img, frame.planes[0], 3) - - return frame - - @staticmethod - def from_ndarray(array, format='rgb24'): - """ - Construct a frame from a numpy array. - - .. note:: for ``pal8``, an ``(image, palette)`` pair must be passed. - `palette` must have shape (256, 4) and is given in ARGB format - (PyAV will swap bytes if needed). - """ - if format == 'pal8': - array, palette = array - assert array.dtype == 'uint8' - assert array.ndim == 2 - assert palette.dtype == 'uint8' - assert palette.shape == (256, 4) - frame = VideoFrame(array.shape[1], array.shape[0], format) - copy_array_to_plane(array, frame.planes[0], 1) - frame.planes[1].update(palette.view('>i4').astype('i4').tobytes()) - return frame - - if format in ('yuv420p', 'yuvj420p'): - assert array.dtype == 'uint8' - assert array.ndim == 2 - assert array.shape[0] % 3 == 0 - assert array.shape[1] % 2 == 0 - frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format) - u_start = frame.width * frame.height - v_start = 5 * u_start // 4 - flat = array.reshape(-1) - copy_array_to_plane(flat[0:u_start], frame.planes[0], 1) - copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 1) - copy_array_to_plane(flat[v_start:], frame.planes[2], 1) - return frame - elif format == 'yuyv422': - assert array.dtype == 'uint8' - assert array.ndim == 3 - assert array.shape[0] % 2 == 0 - assert array.shape[1] % 2 == 0 - assert array.shape[2] == 2 - elif format in ('rgb24', 'bgr24'): - assert array.dtype == 'uint8' - assert array.ndim == 3 - assert array.shape[2] == 3 - elif format in ('argb', 'rgba', 'abgr', 'bgra'): - assert array.dtype == 'uint8' - assert array.ndim == 3 - assert array.shape[2] == 4 - elif format in ('gray', 'gray8', 'rgb8', 'bgr8'): - assert array.dtype == 'uint8' - assert array.ndim == 2 - else: - raise ValueError('Conversion from numpy array with format `%s` is not yet supported' % format) - - frame = VideoFrame(array.shape[1], array.shape[0], format) - copy_array_to_plane(array, frame.planes[0], 1 if array.ndim == 2 else array.shape[2]) - - return frame diff --git a/av/video/plane.pxd b/av/video/plane.pxd index f9abf22b6..80ff79050 100644 --- a/av/video/plane.pxd +++ b/av/video/plane.pxd @@ -1,8 +1,38 @@ +from libc.stdint cimport int64_t, uint8_t, uint16_t, uint64_t + from av.plane cimport Plane from av.video.format cimport VideoFormatComponent cdef class VideoPlane(Plane): - cdef readonly size_t buffer_size cdef readonly unsigned int width, height + + +cdef enum DeviceType: + kCPU = 1 + kCuda = 2 + +cdef struct DLDataType: + uint8_t code + uint8_t bits + uint16_t lanes + +cdef struct DLTensor: + void* data + int device_type + int device_id + int ndim + DLDataType dtype + int64_t* shape + int64_t* strides + uint64_t byte_offset + +cdef struct DLManagedTensor + +ctypedef void (*DLManagedTensorDeleter)(DLManagedTensor*) noexcept nogil + +cdef struct DLManagedTensor: + DLTensor dl_tensor + void* manager_ctx + DLManagedTensorDeleter deleter diff --git a/av/video/plane.py b/av/video/plane.py new file mode 100644 index 000000000..51a3a13f1 --- /dev/null +++ b/av/video/plane.py @@ -0,0 +1,373 @@ +import cython +import cython.cimports.libav as lib +from cython.cimports.av.error import err_check +from cython.cimports.av.video.format import ( + VideoFormatComponent, + get_pix_fmt, + get_video_format, +) +from cython.cimports.av.video.frame import VideoFrame +from cython.cimports.cpython import PyBUF_WRITABLE, PyBuffer_FillInfo +from cython.cimports.cpython.buffer import Py_buffer +from cython.cimports.cpython.pycapsule import ( + PyCapsule_GetPointer, + PyCapsule_IsValid, + PyCapsule_New, +) +from cython.cimports.libc.stdlib import free, malloc + + +@cython.final +@cython.cclass +class VideoPlane(Plane): + def __cinit__(self, frame: VideoFrame, index: cython.int): + # The palette plane has no associated component or linesize; set fields manually + fmt = frame.format + if frame.ptr.hw_frames_ctx: + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], frame.ptr.hw_frames_ctx.data + ) + fmt = get_video_format( + frames_ctx.sw_format, frame.ptr.width, frame.ptr.height + ) + + if index == 1 and fmt.name == "pal8": + self.width = 256 + self.height = 1 + self.buffer_size = 256 * 4 + return + + for i in range(fmt.ptr.nb_components): + if fmt.ptr.comp[i].plane == index: + component = VideoFormatComponent(fmt, i) + self.width = component.width + self.height = component.height + break + else: + raise RuntimeError(f"could not find plane {index} of {fmt!r}") + + # Sometimes, linesize is negative (and that is meaningful). We are only + # insisting that the buffer size be based on the extent of linesize, and + # ignore it's direction. + self.buffer_size = abs(self.frame.ptr.linesize[self.index]) * self.height + + @cython.cfunc + def _buffer_size(self) -> cython.size_t: + return self.buffer_size + + @property + def line_size(self): + """ + Bytes per horizontal line in this plane. + + :type: int + """ + return self.frame.ptr.linesize[self.index] + + @cython.cfunc + def _buffer_writable(self) -> cython.bint: + if self.frame.ptr.hw_frames_ctx: + return False + return True + + def __getbuffer__(self, view: cython.pointer[Py_buffer], flags: cython.int): + if self.frame.ptr.hw_frames_ctx: + raise TypeError( + "Hardware frame planes do not support the Python buffer protocol. " + "Use DLPack (__dlpack__) or download to a software frame." + ) + if flags & PyBUF_WRITABLE and not self._buffer_writable(): + raise ValueError("buffer is not writable") + + ptr: cython.p_void = self._buffer_ptr() + line_size: cython.int = self.frame.ptr.linesize[self.index] + if line_size < 0: + height: cython.int = self.height + ptr = cython.cast(cython.p_char, ptr) + (height - 1) * line_size + + PyBuffer_FillInfo(view, self, ptr, self._buffer_size(), 0, flags) + + def __dlpack_device__(self): + if self.frame.ptr.hw_frames_ctx: + if cython.cast(lib.AVPixelFormat, self.frame.ptr.format) != get_pix_fmt( + b"cuda" + ): + raise NotImplementedError( + "DLPack export is only implemented for CUDA hw frames" + ) + return (kCuda, self.frame._device_id) + return (kCPU, 0) + + def __dlpack__(self, *, stream: int | None = None): + if self.frame.ptr.buf[0] == cython.NULL: + raise TypeError( + "DLPack export requires a refcounted AVFrame (frame.buf[0] is NULL)" + ) + + sw_fmt: lib.AVPixelFormat + device_type: cython.int + device_id: cython.int + + if self.frame.ptr.hw_frames_ctx: + if cython.cast(lib.AVPixelFormat, self.frame.ptr.format) != get_pix_fmt( + b"cuda" + ): + raise NotImplementedError( + "DLPack export is only implemented for CUDA hw frames" + ) + if stream is not None: + raise NotImplementedError( + "CUDA stream synchronization is not supported. " + "Pass stream=None and synchronize before calling __dlpack__." + ) + + frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast( + cython.pointer[lib.AVHWFramesContext], self.frame.ptr.hw_frames_ctx.data + ) + sw_fmt = frames_ctx.sw_format + device_type = kCuda + device_id = self.frame._device_id + else: + sw_fmt = cython.cast(lib.AVPixelFormat, self.frame.ptr.format) + device_type = kCPU + device_id = 0 + + line_size = self.line_size + if line_size < 0: + raise NotImplementedError( + "negative linesize is not supported for DLPack export" + ) + + nv12 = get_pix_fmt(b"nv12") + p010le = get_pix_fmt(b"p010le") + p016le = get_pix_fmt(b"p016le") + + ndim, bits, itemsize = cython.declare(cython.int) + s0, s1, s2 = cython.declare(int64_t) + st0, st1, st2 = cython.declare(int64_t) + + if sw_fmt == nv12: + itemsize = 1 + bits = 8 + if self.index == 0: + ndim = 2 + s0 = self.frame.ptr.height + s1 = self.frame.ptr.width + st0 = line_size + st1 = 1 + elif self.index == 1: + ndim = 3 + s0 = self.frame.ptr.height // 2 + s1 = self.frame.ptr.width // 2 + s2 = 2 + st0 = line_size + st1 = 2 + st2 = 1 + else: + raise ValueError("invalid plane index for NV12") + elif sw_fmt == p010le or sw_fmt == p016le: + itemsize = 2 + bits = 16 + if line_size % itemsize: + raise ValueError("linesize is not aligned to dtype") + if self.index == 0: + ndim = 2 + s0 = self.frame.ptr.height + s1 = self.frame.ptr.width + st0 = line_size // itemsize + st1 = 1 + elif self.index == 1: + ndim = 3 + s0 = self.frame.ptr.height // 2 + s1 = self.frame.ptr.width // 2 + s2 = 2 + st0 = line_size // itemsize + st1 = 2 + st2 = 1 + else: + raise ValueError("invalid plane index for P010/P016") + elif device_type != kCPU: + raise NotImplementedError("unsupported sw_format for DLPack export") + else: + # Generic CPU export. Describe the plane straight from its + # pixel-format descriptor: planes holding a single component (the + # Y/U/V planes of planar YUV, gray, ...) become 2D (H, W), while + # planes that interleave several components (packed RGB, the chroma + # plane of NV12, ...) become 3D (H, W, C). + desc: cython.pointer[cython.const[lib.AVPixFmtDescriptor]] = ( + lib.av_pix_fmt_desc_get(sw_fmt) + ) + if desc == cython.NULL: + raise NotImplementedError("unknown pixel format for DLPack export") + if desc.flags & ( + lib.AV_PIX_FMT_FLAG_BITSTREAM + | lib.AV_PIX_FMT_FLAG_PAL + | lib.AV_PIX_FMT_FLAG_BAYER + ): + raise NotImplementedError( + "bitstream, palette, and Bayer formats are not supported for " + "DLPack export" + ) + + step_bytes: cython.int = 0 + ncomp: cython.int = 0 + i: cython.int + for i in range(desc.nb_components): + if desc.comp[i].plane != self.index: + continue + if ncomp == 0: + step_bytes = desc.comp[i].step + elif cython.cast(cython.int, desc.comp[i].step) != step_bytes: + raise NotImplementedError( + "mixed component step is not supported for DLPack export" + ) + ncomp += 1 + + if ncomp == 0: + raise ValueError(f"plane {self.index} has no components") + if step_bytes % ncomp: + raise NotImplementedError( + "unsupported component packing for DLPack export" + ) + itemsize = step_bytes // ncomp + if itemsize != 1 and itemsize != 2: + raise NotImplementedError( + "only 8- and 16-bit components are supported for DLPack export" + ) + if itemsize == 2 and desc.flags & lib.AV_PIX_FMT_FLAG_BE: + raise NotImplementedError( + "big-endian formats are not supported for DLPack export" + ) + bits = itemsize * 8 + if line_size % itemsize: + raise ValueError("linesize is not aligned to dtype") + + if ncomp == 1: + ndim = 2 + s0 = self.height + s1 = self.width + st0 = line_size // itemsize + st1 = 1 + else: + ndim = 3 + s0 = self.height + s1 = self.width + s2 = ncomp + st0 = line_size // itemsize + st1 = ncomp + st2 = 1 + + frame_ref: cython.pointer[lib.AVFrame] = lib.av_frame_alloc() + if frame_ref == cython.NULL: + raise MemoryError("av_frame_alloc() failed") + err_check(lib.av_frame_ref(frame_ref, self.frame.ptr)) + + shape = cython.cast( + cython.pointer[int64_t], malloc(ndim * cython.sizeof(int64_t)) + ) + strides = cython.cast( + cython.pointer[int64_t], malloc(ndim * cython.sizeof(int64_t)) + ) + if shape == cython.NULL or strides == cython.NULL: + if shape != cython.NULL: + free(shape) + if strides != cython.NULL: + free(strides) + lib.av_frame_free(cython.address(frame_ref)) + raise MemoryError("malloc() failed") + + if ndim == 2: + shape[0] = s0 + shape[1] = s1 + strides[0] = st0 + strides[1] = st1 + else: + shape[0] = s0 + shape[1] = s1 + shape[2] = s2 + strides[0] = st0 + strides[1] = st1 + strides[2] = st2 + + ctx = cython.cast( + cython.pointer[cython.p_void], malloc(3 * cython.sizeof(cython.p_void)) + ) + if ctx == cython.NULL: + free(shape) + free(strides) + lib.av_frame_free(cython.address(frame_ref)) + raise MemoryError("malloc() failed") + + ctx[0] = cython.cast(cython.p_void, frame_ref) + ctx[1] = cython.cast(cython.p_void, shape) + ctx[2] = cython.cast(cython.p_void, strides) + + managed = cython.cast( + cython.pointer[DLManagedTensor], malloc(cython.sizeof(DLManagedTensor)) + ) + if managed == cython.NULL: + free(ctx) + free(shape) + free(strides) + lib.av_frame_free(cython.address(frame_ref)) + raise MemoryError("malloc() failed") + + managed.dl_tensor.data = cython.cast(cython.p_void, frame_ref.data[self.index]) + managed.dl_tensor.device_type = device_type + managed.dl_tensor.device_id = device_id + managed.dl_tensor.ndim = ndim + managed.dl_tensor.dtype = DLDataType(code=1, bits=bits, lanes=1) + managed.dl_tensor.shape = shape + managed.dl_tensor.strides = strides + managed.dl_tensor.byte_offset = 0 + managed.manager_ctx = cython.cast(cython.p_void, ctx) + managed.deleter = _dlpack_managed_tensor_deleter + + try: + capsule = PyCapsule_New( + cython.cast(cython.p_void, managed), + b"dltensor", + _dlpack_capsule_destructor, + ) + except Exception: + _dlpack_managed_tensor_deleter(managed) + raise + + return capsule + + +@cython.cfunc +@cython.nogil +@cython.exceptval(check=False) +def _dlpack_managed_tensor_deleter( + managed: cython.pointer[DLManagedTensor], +) -> cython.void: + if managed == cython.NULL: + return + ctx = cython.cast(cython.pointer[cython.p_void], managed.manager_ctx) + if ctx != cython.NULL: + frame_ref = cython.cast(cython.pointer[lib.AVFrame], ctx[0]) + shape = cython.cast(cython.pointer[int64_t], ctx[1]) + strides = cython.cast(cython.pointer[int64_t], ctx[2]) + + if frame_ref != cython.NULL: + lib.av_frame_free(cython.address(frame_ref)) + if shape != cython.NULL: + free(shape) + if strides != cython.NULL: + free(strides) + free(ctx) + + free(managed) + + +@cython.cfunc +@cython.exceptval(check=False) +def _dlpack_capsule_destructor(capsule: object) -> cython.void: + if PyCapsule_IsValid(capsule, b"dltensor"): + managed = cython.cast( + cython.pointer[DLManagedTensor], + PyCapsule_GetPointer(capsule, b"dltensor"), + ) + if managed != cython.NULL: + managed.deleter(managed) diff --git a/av/video/plane.pyi b/av/video/plane.pyi new file mode 100644 index 000000000..fcbf8e6ed --- /dev/null +++ b/av/video/plane.pyi @@ -0,0 +1,15 @@ +from types import CapsuleType + +from av.plane import Plane + +from .frame import VideoFrame + +class VideoPlane(Plane): + line_size: int + width: int + height: int + buffer_size: int + + def __init__(self, frame: VideoFrame, index: int) -> None: ... + def __dlpack_device__(self) -> tuple[int, int]: ... + def __dlpack__(self, *, stream: int | None = None) -> CapsuleType: ... diff --git a/av/video/plane.pyx b/av/video/plane.pyx deleted file mode 100644 index 6f1286ca3..000000000 --- a/av/video/plane.pyx +++ /dev/null @@ -1,39 +0,0 @@ -from av.video.frame cimport VideoFrame - - -cdef class VideoPlane(Plane): - - def __cinit__(self, VideoFrame frame, int index): - - # The palette plane has no associated component or linesize; set fields manually - if frame.format.name == 'pal8' and index == 1: - self.width = 256 - self.height = 1 - self.buffer_size = 256 * 4 - return - - for i in range(frame.format.ptr.nb_components): - if frame.format.ptr.comp[i].plane == index: - component = frame.format.components[i] - self.width = component.width - self.height = component.height - break - else: - raise RuntimeError('could not find plane %d of %r' % (index, frame.format)) - - # Sometimes, linesize is negative (and that is meaningful). We are only - # insisting that the buffer size be based on the extent of linesize, and - # ignore it's direction. - self.buffer_size = abs(self.frame.ptr.linesize[self.index]) * self.height - - cdef size_t _buffer_size(self): - return self.buffer_size - - property line_size: - """ - Bytes per horizontal line in this plane. - - :type: int - """ - def __get__(self): - return self.frame.ptr.linesize[self.index] diff --git a/av/video/reformatter.pxd b/av/video/reformatter.pxd index 25135c27a..1645d3943 100644 --- a/av/video/reformatter.pxd +++ b/av/video/reformatter.pxd @@ -3,10 +3,48 @@ cimport libav as lib from av.video.frame cimport VideoFrame -cdef class VideoReformatter(object): +cdef extern from "libswscale/swscale.h" nogil: + cdef struct SwsContext: + unsigned flags + int threads - cdef lib.SwsContext *ptr + cdef int SWS_FAST_BILINEAR + cdef int SWS_BILINEAR + cdef int SWS_BICUBIC + cdef int SWS_X + cdef int SWS_POINT + cdef int SWS_AREA + cdef int SWS_BICUBLIN + cdef int SWS_GAUSS + cdef int SWS_SINC + cdef int SWS_LANCZOS + cdef int SWS_SPLINE + cdef int SWS_PRINT_INFO + cdef int SWS_FULL_CHR_H_INT + cdef int SWS_FULL_CHR_H_INP + cdef int SWS_DIRECT_BGR + cdef int SWS_ACCURATE_RND + cdef int SWS_BITEXACT + cdef int SWS_ERROR_DIFFUSION + cdef int SWS_CS_ITU709 + cdef int SWS_CS_FCC + cdef int SWS_CS_ITU601 + cdef int SWS_CS_ITU624 + cdef int SWS_CS_SMPTE170M + cdef int SWS_CS_SMPTE240M + cdef int SWS_CS_DEFAULT + cdef int SWS_CS_BT2020 + cdef SwsContext *sws_alloc_context() + cdef void sws_free_context(SwsContext **ctx) + cdef int sws_is_noop(const lib.AVFrame *dst, const lib.AVFrame *src) + cdef int sws_scale_frame(SwsContext *c, lib.AVFrame *dst, const lib.AVFrame *src) + +cdef class VideoReformatter: + cdef SwsContext *ptr cdef _reformat(self, VideoFrame frame, int width, int height, lib.AVPixelFormat format, int src_colorspace, - int dst_colorspace, int interpolation) + int dst_colorspace, int interpolation, + int src_color_range, int dst_color_range, + int dst_color_trc, int dst_color_primaries, + int threads) diff --git a/av/video/reformatter.py b/av/video/reformatter.py new file mode 100644 index 000000000..59725571b --- /dev/null +++ b/av/video/reformatter.py @@ -0,0 +1,359 @@ +from enum import IntEnum, IntFlag + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.error import err_check +from cython.cimports.av.video.format import VideoFormat, get_pix_fmt +from cython.cimports.av.video.frame import alloc_video_frame + + +class Interpolation(IntFlag): + FAST_BILINEAR: "Fast bilinear" = SWS_FAST_BILINEAR + BILINEAR: "Bilinear" = SWS_BILINEAR + BICUBIC: "2-tap cubic B-spline" = SWS_BICUBIC + X: "Experimental" = SWS_X + POINT: "Nearest neighbor / point" = SWS_POINT + AREA: "Area averaging" = SWS_AREA + BICUBLIN: "Bicubic luma / Bilinear chroma" = SWS_BICUBLIN + GAUSS: "Gaussian approximation" = SWS_GAUSS + SINC: "Unwindowed Sinc" = SWS_SINC + LANCZOS: "3-tap sinc/sinc" = SWS_LANCZOS + SPLINE: "Unwindowed natural cubic spline" = SWS_SPLINE + PRINT_INFO: "Emit verbose scaler info to the log" = SWS_PRINT_INFO + FULL_CHR_H_INT: "Full chroma interpolation" = SWS_FULL_CHR_H_INT + FULL_CHR_H_INP: "Full chroma input" = SWS_FULL_CHR_H_INP + DIRECT_BGR: "Direct BGR" = SWS_DIRECT_BGR + ACCURATE_RND: "Accurate rounding" = SWS_ACCURATE_RND + BITEXACT: "Bit-exact output" = SWS_BITEXACT + ERROR_DIFFUSION: "Error diffusion dither" = SWS_ERROR_DIFFUSION + + +class Colorspace(IntEnum): + ITU709 = SWS_CS_ITU709 + FCC = SWS_CS_FCC + ITU601 = SWS_CS_ITU601 + ITU624 = SWS_CS_ITU624 + SMPTE170M = SWS_CS_SMPTE170M + SMPTE240M = SWS_CS_SMPTE240M + DEFAULT = SWS_CS_DEFAULT + BT2020 = SWS_CS_BT2020 + # Lowercase for b/c. + itu709 = SWS_CS_ITU709 + fcc = SWS_CS_FCC + itu601 = SWS_CS_ITU601 + itu624 = SWS_CS_ITU624 + smpte170m = SWS_CS_SMPTE170M + smpte240m = SWS_CS_SMPTE240M + default = SWS_CS_DEFAULT + bt2020 = SWS_CS_BT2020 + + +class ColorRange(IntEnum): + UNSPECIFIED: "Unspecified" = lib.AVCOL_RANGE_UNSPECIFIED + MPEG: "MPEG (limited) YUV range, 219*2^(n-8)" = lib.AVCOL_RANGE_MPEG + JPEG: "JPEG (full) YUV range, 2^n-1" = lib.AVCOL_RANGE_JPEG + NB: "Not part of ABI" = lib.AVCOL_RANGE_NB + + +class ColorTrc(IntEnum): + """Transfer characteristic (gamma curve) of a video frame. + + Maps to FFmpeg's ``AVColorTransferCharacteristic``. + """ + + BT709: "BT.709" = lib.AVCOL_TRC_BT709 + UNSPECIFIED: "Unspecified" = lib.AVCOL_TRC_UNSPECIFIED + GAMMA22: "Gamma 2.2 (BT.470M)" = lib.AVCOL_TRC_GAMMA22 + GAMMA28: "Gamma 2.8 (BT.470BG)" = lib.AVCOL_TRC_GAMMA28 + SMPTE170M: "SMPTE 170M" = lib.AVCOL_TRC_SMPTE170M + SMPTE240M: "SMPTE 240M" = lib.AVCOL_TRC_SMPTE240M + LINEAR: "Linear" = lib.AVCOL_TRC_LINEAR + LOG: "Logarithmic (100:1 range)" = lib.AVCOL_TRC_LOG + LOG_SQRT: "Logarithmic (100*sqrt(10):1 range)" = lib.AVCOL_TRC_LOG_SQRT + IEC61966_2_4: "IEC 61966-2-4 (sRGB)" = lib.AVCOL_TRC_IEC61966_2_4 + BT1361_ECG: "BT.1361 extended colour gamut" = lib.AVCOL_TRC_BT1361_ECG + IEC61966_2_1: "IEC 61966-2-1 (sYCC)" = lib.AVCOL_TRC_IEC61966_2_1 + BT2020_10: "BT.2020 10-bit" = lib.AVCOL_TRC_BT2020_10 + BT2020_12: "BT.2020 12-bit" = lib.AVCOL_TRC_BT2020_12 + SMPTE2084: "SMPTE 2084 (PQ, HDR10)" = lib.AVCOL_TRC_SMPTE2084 + SMPTE428: "SMPTE 428-1" = lib.AVCOL_TRC_SMPTE428 + ARIB_STD_B67: "ARIB STD-B67 (HLG)" = lib.AVCOL_TRC_ARIB_STD_B67 + + +class ColorPrimaries(IntEnum): + """Color primaries of a video frame. + + Maps to FFmpeg's ``AVColorPrimaries``. + """ + + BT709: "BT.709 / sRGB / sYCC" = lib.AVCOL_PRI_BT709 + UNSPECIFIED: "Unspecified" = lib.AVCOL_PRI_UNSPECIFIED + BT470M: "BT.470M" = lib.AVCOL_PRI_BT470M + BT470BG: "BT.470BG / BT.601-6 625" = lib.AVCOL_PRI_BT470BG + SMPTE170M: "SMPTE 170M / BT.601-6 525" = lib.AVCOL_PRI_SMPTE170M + SMPTE240M: "SMPTE 240M" = lib.AVCOL_PRI_SMPTE240M + FILM: "Generic film (Illuminant C)" = lib.AVCOL_PRI_FILM + BT2020: "BT.2020 / BT.2100" = lib.AVCOL_PRI_BT2020 + SMPTE428: "SMPTE 428-1 / XYZ" = lib.AVCOL_PRI_SMPTE428 + SMPTE431: "SMPTE 431-2 (DCI-P3)" = lib.AVCOL_PRI_SMPTE431 + SMPTE432: "SMPTE 432-1 (Display P3)" = lib.AVCOL_PRI_SMPTE432 + EBU3213: "EBU 3213-E / JEDEC P22" = lib.AVCOL_PRI_EBU3213 + + +@cython.cfunc +@cython.inline +def _resolve_enum_value( + value: object, enum_class: object, default: cython.int +) -> cython.int: + # Helper function to resolve enum values from different input types. + if value is None: + return default + if isinstance(value, enum_class): + return value.value + if isinstance(value, int): + return value + if isinstance(value, str): + return enum_class[value].value + raise ValueError(f"Cannot convert {value} to {enum_class.__name__}") + + +@cython.cfunc +@cython.inline +def _resolve_format(format: object, default: lib.AVPixelFormat) -> lib.AVPixelFormat: + if format is None: + return default + if isinstance(format, VideoFormat): + return cython.cast(VideoFormat, format).pix_fmt + return get_pix_fmt(format) + + +@cython.cfunc +def _set_frame_colorspace( + frame: cython.pointer(lib.AVFrame), + colorspace: cython.int, + color_range: cython.int, +): + """Set AVFrame colorspace/range from SWS_CS_* and AVColorRange values.""" + if color_range != lib.AVCOL_RANGE_UNSPECIFIED: + frame.color_range = cython.cast(lib.AVColorRange, color_range) + # Mapping from SWS_CS_* (swscale colorspace) to AVColorSpace (frame metadata). + # Note: SWS_CS_ITU601, SWS_CS_ITU624, SWS_CS_SMPTE170M, and SWS_CS_DEFAULT all have + # the same value (5), so we map 5 -> AVCOL_SPC_SMPTE170M as the most common case. + # SWS_CS_DEFAULT is handled specially by not setting frame metadata. + if colorspace == SWS_CS_ITU709: + frame.colorspace = lib.AVCOL_SPC_BT709 + elif colorspace == SWS_CS_FCC: + frame.colorspace = lib.AVCOL_SPC_FCC + elif colorspace == SWS_CS_ITU601: + frame.colorspace = lib.AVCOL_SPC_SMPTE170M + elif colorspace == SWS_CS_SMPTE240M: + frame.colorspace = lib.AVCOL_SPC_SMPTE240M + elif colorspace == SWS_CS_BT2020: + frame.colorspace = lib.AVCOL_SPC_BT2020_NCL + + +@cython.final +@cython.cclass +class VideoReformatter: + """An object for reformatting size and pixel format of :class:`.VideoFrame`. + + It is most efficient to have a reformatter object for each set of parameters + you will use as calling :meth:`reformat` will reconfigure the internal object. + + """ + + def __dealloc__(self): + with cython.nogil: + sws_free_context(cython.address(self.ptr)) + + def reformat( + self, + frame: VideoFrame, + width=None, + height=None, + format=None, + src_colorspace=None, + dst_colorspace=None, + interpolation=None, + src_color_range=None, + dst_color_range=None, + dst_color_trc=None, + dst_color_primaries=None, + threads=None, + ): + """Create a new :class:`VideoFrame` with the given width/height/format/colorspace. + + Returns the same frame untouched if nothing needs to be done to it. + + :param int width: New width, or ``None`` for the same width. + :param int height: New height, or ``None`` for the same height. + :param format: New format, or ``None`` for the same format. + :type format: :class:`.VideoFormat` or ``str`` + :param src_colorspace: Current colorspace, or ``None`` for the frame colorspace. + :type src_colorspace: :class:`Colorspace` or ``str`` + :param dst_colorspace: Desired colorspace, or ``None`` for the frame colorspace. + :type dst_colorspace: :class:`Colorspace` or ``str`` + :param interpolation: The scaling algorithm to use, or ``None`` for ``BILINEAR``. + Option flags such as ``ACCURATE_RND`` or ``BITEXACT`` may be combined with + the algorithm using ``|``, e.g. ``Interpolation.BILINEAR | Interpolation.ACCURATE_RND``. + :type interpolation: :class:`Interpolation` or ``str`` + :param src_color_range: Current color range, or ``None`` for the ``UNSPECIFIED``. + :type src_color_range: :class:`ColorRange` or ``str`` + :param dst_color_range: Desired color range, or ``None`` for the ``UNSPECIFIED``. + :type dst_color_range: :class:`ColorRange` or ``str`` + :param dst_color_trc: Desired transfer characteristic to tag on the output frame, + or ``None`` to preserve the source frame's value. This sets frame metadata only; + it does not perform a pixel-level transfer function conversion. + :type dst_color_trc: :class:`ColorTrc` or ``int`` + :param dst_color_primaries: Desired color primaries to tag on the output frame, + or ``None`` to preserve the source frame's value. + :type dst_color_primaries: :class:`ColorPrimaries` or ``int`` + :param int threads: How many threads to use for scaling, or ``0`` for automatic + selection based on the number of available CPUs. Defaults to ``0`` (auto). + + """ + c_dst_format = _resolve_format(format, frame.format.pix_fmt) + c_src_colorspace = _resolve_enum_value( + src_colorspace, Colorspace, frame.ptr.colorspace + ) + c_dst_colorspace = _resolve_enum_value( + dst_colorspace, Colorspace, frame.ptr.colorspace + ) + c_interpolation = _resolve_enum_value( + interpolation, Interpolation, SWS_BILINEAR + ) + c_src_color_range = _resolve_enum_value(src_color_range, ColorRange, 0) + c_dst_color_range = _resolve_enum_value(dst_color_range, ColorRange, 0) + # Default to UNSPECIFIED (not the source's value) so that a transfer / + # primaries conversion is only performed when explicitly requested. See + # _reformat for why. + c_dst_color_trc = _resolve_enum_value( + dst_color_trc, ColorTrc, lib.AVCOL_TRC_UNSPECIFIED + ) + c_dst_color_primaries = _resolve_enum_value( + dst_color_primaries, ColorPrimaries, lib.AVCOL_PRI_UNSPECIFIED + ) + c_threads: cython.int = threads if threads is not None else 0 + c_width: cython.int = width if width is not None else frame.ptr.width + c_height: cython.int = height if height is not None else frame.ptr.height + + return self._reformat( + frame, + c_width, + c_height, + c_dst_format, + c_src_colorspace, + c_dst_colorspace, + c_interpolation, + c_src_color_range, + c_dst_color_range, + c_dst_color_trc, + c_dst_color_primaries, + c_threads, + ) + + @cython.cfunc + def _reformat( + self, + frame: VideoFrame, + width: cython.int, + height: cython.int, + dst_format: lib.AVPixelFormat, + src_colorspace: cython.int, + dst_colorspace: cython.int, + interpolation: cython.int, + src_color_range: cython.int, + dst_color_range: cython.int, + dst_color_trc: cython.int, + dst_color_primaries: cython.int, + threads: cython.int, + ): + if frame.ptr.hw_frames_ctx: + frame_sw = alloc_video_frame() + err_check(lib.av_hwframe_transfer_data(frame_sw.ptr, frame.ptr, 0)) + frame_sw._copy_internal_attributes(frame, data_layout=False) + frame_sw._init_user_attributes() + frame = frame_sw + + new_frame: VideoFrame = alloc_video_frame() + new_frame._copy_internal_attributes(frame, data_layout=False) + new_frame.ptr.format = dst_format + new_frame.ptr.width = width + new_frame.ptr.height = height + + # A transfer-characteristic / primaries conversion is opt-in. Unlike the + # pre-17.0 sws_scale, sws_scale_frame inspects color_trc/color_primaries + # and rejects RESERVED (and other unsupported) values with EOPNOTSUPP, + # which regressed plain reformats of e.g. VP9 / NVDEC frames (#2208). So + # only feed these fields to swscale when the caller explicitly requested a + # destination value; otherwise neutralize them for the scale (as the old + # sws_scale effectively did) while still preserving the source's tags on + # the returned frame's metadata. + convert_trc: cython.bint = dst_color_trc != lib.AVCOL_TRC_UNSPECIFIED + convert_primaries: cython.bint = ( + dst_color_primaries != lib.AVCOL_PRI_UNSPECIFIED + ) + frame_src_color_trc: lib.AVColorTransferCharacteristic = frame.ptr.color_trc + frame_src_color_primaries: lib.AVColorPrimaries = frame.ptr.color_primaries + + if convert_trc: + new_frame.ptr.color_trc = cython.cast( + lib.AVColorTransferCharacteristic, dst_color_trc + ) + else: + frame.ptr.color_trc = lib.AVCOL_TRC_UNSPECIFIED + new_frame.ptr.color_trc = lib.AVCOL_TRC_UNSPECIFIED + + if convert_primaries: + new_frame.ptr.color_primaries = cython.cast( + lib.AVColorPrimaries, dst_color_primaries + ) + else: + frame.ptr.color_primaries = lib.AVCOL_PRI_UNSPECIFIED + new_frame.ptr.color_primaries = lib.AVCOL_PRI_UNSPECIFIED + + # Translate source and destination colorspace/range from SWS_CS_* to AVCOL_* + # so sws_is_noop and sws_scale_frame understand them + frame_src_colorspace: lib.AVColorSpace = frame.ptr.colorspace + frame_src_color_range: lib.AVColorRange = frame.ptr.color_range + _set_frame_colorspace(frame.ptr, src_colorspace, src_color_range) + _set_frame_colorspace(new_frame.ptr, dst_colorspace, dst_color_range) + + # Shortcut if sws_scale_frame would be a no-op + is_noop: cython.bint = sws_is_noop(new_frame.ptr, frame.ptr) != 0 + if is_noop: + # Restore source frame metadata to avoid side effects + frame.ptr.colorspace = frame_src_colorspace + frame.ptr.color_range = frame_src_color_range + frame.ptr.color_trc = frame_src_color_trc + frame.ptr.color_primaries = frame_src_color_primaries + return frame + + if self.ptr == cython.NULL: + self.ptr = sws_alloc_context() + if self.ptr == cython.NULL: + raise MemoryError("Could not allocate SwsContext") + self.ptr.threads = threads + self.ptr.flags = cython.cast(cython.uint, interpolation) + + # Allocate frame buffers and perform the conversion + new_frame._init(dst_format, width, height) + with cython.nogil: + ret = sws_scale_frame(self.ptr, new_frame.ptr, frame.ptr) + + # Restore source frame metadata to avoid side effects + frame.ptr.colorspace = frame_src_colorspace + frame.ptr.color_range = frame_src_color_range + frame.ptr.color_trc = frame_src_color_trc + frame.ptr.color_primaries = frame_src_color_primaries + + # Preserve the source's transfer/primaries on the output when no explicit + # conversion was requested (the scale ran with neutralized tags). + if not convert_trc: + new_frame.ptr.color_trc = frame_src_color_trc + if not convert_primaries: + new_frame.ptr.color_primaries = frame_src_color_primaries + + err_check(ret) + + return new_frame diff --git a/av/video/reformatter.pyi b/av/video/reformatter.pyi new file mode 100644 index 000000000..480860d0b --- /dev/null +++ b/av/video/reformatter.pyi @@ -0,0 +1,98 @@ +from enum import IntEnum, IntFlag +from typing import cast + +from .frame import VideoFrame + +class Interpolation(IntFlag): + FAST_BILINEAR = cast(int, ...) + BILINEAR = cast(int, ...) + BICUBIC = cast(int, ...) + X = cast(int, ...) + POINT = cast(int, ...) + AREA = cast(int, ...) + BICUBLIN = cast(int, ...) + GAUSS = cast(int, ...) + SINC = cast(int, ...) + LANCZOS = cast(int, ...) + SPLINE = cast(int, ...) + PRINT_INFO = cast(int, ...) + FULL_CHR_H_INT = cast(int, ...) + FULL_CHR_H_INP = cast(int, ...) + DIRECT_BGR = cast(int, ...) + ACCURATE_RND = cast(int, ...) + BITEXACT = cast(int, ...) + ERROR_DIFFUSION = cast(int, ...) + +class Colorspace(IntEnum): + ITU709 = cast(int, ...) + FCC = cast(int, ...) + ITU601 = cast(int, ...) + ITU624 = cast(int, ...) + SMPTE170M = cast(int, ...) + SMPTE240M = cast(int, ...) + DEFAULT = cast(int, ...) + BT2020 = cast(int, ...) + itu709 = cast(int, ...) + fcc = cast(int, ...) + itu601 = cast(int, ...) + itu624 = cast(int, ...) + smpte170m = cast(int, ...) + smpte240m = cast(int, ...) + default = cast(int, ...) + bt2020 = cast(int, ...) + +class ColorRange(IntEnum): + UNSPECIFIED = 0 + MPEG = 1 + JPEG = 2 + NB = 3 + +class ColorTrc(IntEnum): + BT709 = cast(int, ...) + UNSPECIFIED = cast(int, ...) + GAMMA22 = cast(int, ...) + GAMMA28 = cast(int, ...) + SMPTE170M = cast(int, ...) + SMPTE240M = cast(int, ...) + LINEAR = cast(int, ...) + LOG = cast(int, ...) + LOG_SQRT = cast(int, ...) + IEC61966_2_4 = cast(int, ...) + BT1361_ECG = cast(int, ...) + IEC61966_2_1 = cast(int, ...) + BT2020_10 = cast(int, ...) + BT2020_12 = cast(int, ...) + SMPTE2084 = cast(int, ...) + SMPTE428 = cast(int, ...) + ARIB_STD_B67 = cast(int, ...) + +class ColorPrimaries(IntEnum): + BT709 = cast(int, ...) + UNSPECIFIED = cast(int, ...) + BT470M = cast(int, ...) + BT470BG = cast(int, ...) + SMPTE170M = cast(int, ...) + SMPTE240M = cast(int, ...) + FILM = cast(int, ...) + BT2020 = cast(int, ...) + SMPTE428 = cast(int, ...) + SMPTE431 = cast(int, ...) + SMPTE432 = cast(int, ...) + EBU3213 = cast(int, ...) + +class VideoReformatter: + def reformat( + self, + frame: VideoFrame, + width: int | None = None, + height: int | None = None, + format: str | None = None, + src_colorspace: int | None = None, + dst_colorspace: int | None = None, + interpolation: Interpolation | int | str | None = None, + src_color_range: int | str | None = None, + dst_color_range: int | str | None = None, + dst_color_trc: int | ColorTrc | None = None, + dst_color_primaries: int | ColorPrimaries | None = None, + threads: int | None = None, + ) -> VideoFrame: ... diff --git a/av/video/reformatter.pyx b/av/video/reformatter.pyx deleted file mode 100644 index 3ad995fb9..000000000 --- a/av/video/reformatter.pyx +++ /dev/null @@ -1,195 +0,0 @@ -from libc.stdint cimport uint8_t -cimport libav as lib - -from av.enum cimport define_enum -from av.error cimport err_check -from av.video.format cimport VideoFormat -from av.video.frame cimport alloc_video_frame - - -Interpolation = define_enum('Interpolation', __name__, ( - ('FAST_BILINEAR', lib.SWS_FAST_BILINEAR, "Fast bilinear"), - ('BILINEAR', lib.SWS_BILINEAR, "Bilinear"), - ('BICUBIC', lib.SWS_BICUBIC, "Bicubic"), - ('X', lib.SWS_X, "Experimental"), - ('POINT', lib.SWS_POINT, "Nearest neighbor / point"), - ('AREA', lib.SWS_AREA, "Area averaging"), - ('BICUBLIN', lib.SWS_BICUBLIN, "Luma bicubic / chroma bilinear"), - ('GAUSS', lib.SWS_GAUSS, "Gaussian"), - ('SINC', lib.SWS_SINC, "Sinc"), - ('LANCZOS', lib.SWS_LANCZOS, "Lanczos"), - ('SPLINE', lib.SWS_SPLINE, "Bicubic spline"), -)) - -Colorspace = define_enum('Colorspace', __name__, ( - - ('ITU709', lib.SWS_CS_ITU709), - ('FCC', lib.SWS_CS_FCC), - ('ITU601', lib.SWS_CS_ITU601), - ('ITU624', lib.SWS_CS_ITU624), - ('SMPTE170M', lib.SWS_CS_SMPTE170M), - ('SMPTE240M', lib.SWS_CS_SMPTE240M), - ('DEFAULT', lib.SWS_CS_DEFAULT), - - # Lowercase for b/c. - ('itu709', lib.SWS_CS_ITU709), - ('fcc', lib.SWS_CS_FCC), - ('itu601', lib.SWS_CS_ITU601), - ('itu624', lib.SWS_CS_SMPTE170M), - ('smpte240', lib.SWS_CS_SMPTE240M), - ('default', lib.SWS_CS_DEFAULT), - -)) - - -cdef class VideoReformatter(object): - - """An object for reformatting size and pixel format of :class:`.VideoFrame`. - - It is most efficient to have a reformatter object for each set of parameters - you will use as calling :meth:`reformat` will reconfigure the internal object. - - """ - - def __dealloc__(self): - with nogil: - lib.sws_freeContext(self.ptr) - - def reformat(self, VideoFrame frame not None, width=None, height=None, - format=None, src_colorspace=None, dst_colorspace=None, - interpolation=None): - """Create a new :class:`VideoFrame` with the given width/height/format/colorspace. - - Returns the same frame untouched if nothing needs to be done to it. - - :param int width: New width, or ``None`` for the same width. - :param int height: New height, or ``None`` for the same height. - :param format: New format, or ``None`` for the same format. - :type format: :class:`.VideoFormat` or ``str`` - :param src_colorspace: Current colorspace, or ``None`` for ``DEFAULT``. - :type src_colorspace: :class:`Colorspace` or ``str`` - :param dst_colorspace: Desired colorspace, or ``None`` for ``DEFAULT``. - :type dst_colorspace: :class:`Colorspace` or ``str`` - :param interpolation: The interpolation method to use, or ``None`` for ``BILINEAR``. - :type interpolation: :class:`Interpolation` or ``str`` - - """ - - cdef VideoFormat video_format = VideoFormat(format if format is not None else frame.format) - cdef int c_src_colorspace = (Colorspace[src_colorspace] if src_colorspace is not None else Colorspace.DEFAULT).value - cdef int c_dst_colorspace = (Colorspace[dst_colorspace] if dst_colorspace is not None else Colorspace.DEFAULT).value - cdef int c_interpolation = (Interpolation[interpolation] if interpolation is not None else Interpolation.BILINEAR).value - - return self._reformat( - frame, - width or frame.ptr.width, - height or frame.ptr.height, - video_format.pix_fmt, - c_src_colorspace, - c_dst_colorspace, - c_interpolation, - ) - - cdef _reformat(self, VideoFrame frame, int width, int height, - lib.AVPixelFormat dst_format, int src_colorspace, - int dst_colorspace, int interpolation): - - if frame.ptr.format < 0: - raise ValueError("Frame does not have format set.") - - cdef lib.AVPixelFormat src_format = frame.ptr.format - - # Shortcut! - if ( - dst_format == src_format and - width == frame.ptr.width and - height == frame.ptr.height and - dst_colorspace == src_colorspace - ): - return frame - - # Try and reuse existing SwsContextProxy - # VideoStream.decode will copy its SwsContextProxy to VideoFrame - # So all Video frames from the same VideoStream should have the same one - with nogil: - self.ptr = lib.sws_getCachedContext( - self.ptr, - frame.ptr.width, - frame.ptr.height, - src_format, - width, - height, - dst_format, - interpolation, - NULL, - NULL, - NULL - ) - - # We want to change the colorspace transforms. We do that by grabbing - # all of the current settings, changing a couple, and setting them all. - # We need a lot of state here. - cdef const int *inv_tbl - cdef const int *tbl - cdef int src_range, dst_range, brightness, contrast, saturation - cdef int ret - if src_colorspace != dst_colorspace: - - with nogil: - - # Casts for const-ness, because Cython isn't expressive enough. - ret = lib.sws_getColorspaceDetails( - self.ptr, - &inv_tbl, - &src_range, - &tbl, - &dst_range, - &brightness, - &contrast, - &saturation - ) - - err_check(ret) - - with nogil: - - # Grab the coefficients for the requested transforms. - # The inv_table brings us to linear, and `tbl` to the new space. - if src_colorspace != lib.SWS_CS_DEFAULT: - inv_tbl = lib.sws_getCoefficients(src_colorspace) - if dst_colorspace != lib.SWS_CS_DEFAULT: - tbl = lib.sws_getCoefficients(dst_colorspace) - - # Apply! - ret = lib.sws_setColorspaceDetails( - self.ptr, - inv_tbl, - src_range, - tbl, - dst_range, - brightness, - contrast, - saturation - ) - - err_check(ret) - - # Create a new VideoFrame. - cdef VideoFrame new_frame = alloc_video_frame() - new_frame._copy_internal_attributes(frame) - new_frame._init(dst_format, width, height) - - # Finally, scale the image. - with nogil: - lib.sws_scale( - self.ptr, - # Cast for const-ness, because Cython isn't expressive enough. - frame.ptr.data, - frame.ptr.linesize, - 0, # slice Y - frame.ptr.height, - new_frame.ptr.data, - new_frame.ptr.linesize, - ) - - return new_frame diff --git a/av/video/stream.pxd b/av/video/stream.pxd index 582cee22a..1a553f34c 100644 --- a/av/video/stream.pxd +++ b/av/video/stream.pxd @@ -1,6 +1,18 @@ +from libc.stdint cimport int32_t, uint8_t +from av.packet cimport Packet from av.stream cimport Stream +from .frame cimport VideoFrame + cdef class VideoStream(Stream): - pass + # Display matrix (9 int32, native-endian) written as AV_PKT_DATA_DISPLAYMATRIX + # coded side data at mux time, applied only when _has_display_matrix is set. + cdef int32_t _display_matrix[9] + cdef uint8_t _has_display_matrix + + cdef _apply_display_matrix(self) + + cpdef encode(self, VideoFrame frame=?) + cpdef decode(self, Packet packet=?) diff --git a/av/video/stream.py b/av/video/stream.py new file mode 100644 index 000000000..ed8a79aea --- /dev/null +++ b/av/video/stream.py @@ -0,0 +1,191 @@ +import cython +from cython.cimports import libav as lib +from cython.cimports.av.packet import Packet +from cython.cimports.av.stream import Stream +from cython.cimports.av.utils import avrational_to_fraction +from cython.cimports.av.video.frame import VideoFrame +from cython.cimports.libc.stdint import int32_t +from cython.cimports.libc.string import memcpy + + +@cython.final +@cython.cclass +class VideoStream(Stream): + def __repr__(self): + if self.codec_context is None: + return f" at 0x{id(self):x}>" + return ( + f"" + ) + + def __getattr__(self, name): + if name in ("framerate", "rate"): + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + if self.codec_context is None: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + return getattr(self.codec_context, name) + + @cython.ccall + def encode(self, frame: VideoFrame | None = None): + """ + Encode an :class:`.VideoFrame` and return a list of :class:`.Packet`. + + :rtype: list[Packet] + + .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. + """ + self._assert_has_codec_context(lib.AVERROR_ENCODER_NOT_FOUND) + packets = self.codec_context.encode(frame) + packet: Packet + for packet in packets: + packet._stream = self + packet.ptr.stream_index = self.ptr.index + return packets + + @cython.ccall + def decode(self, packet: Packet | None = None): + """ + Decode a :class:`.Packet` and return a list of :class:`.VideoFrame`. + + :rtype: list[VideoFrame] + + .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. + """ + self._assert_has_codec_context() + return self.codec_context.decode(packet) + + @cython.cfunc + def _finalize_for_output(self): + Stream._finalize_for_output(self) + if self.codec_context is not None: + self.ptr.avg_frame_rate = self.codec_context.ptr.framerate + # avcodec_parameters_from_context() overwrites codecpar.coded_side_data, + # so inject the display matrix after it, before avformat_write_header(). + if self.codec_context is not None and self._has_display_matrix: + self._apply_display_matrix() + + @cython.cfunc + def _apply_display_matrix(self): + n: cython.int = 9 * cython.sizeof(int32_t) + sd: cython.pointer[lib.AVPacketSideData] = lib.av_packet_side_data_new( + cython.address(self.ptr.codecpar.coded_side_data), + cython.address(self.ptr.codecpar.nb_coded_side_data), + lib.AV_PKT_DATA_DISPLAYMATRIX, + n, + 0, + ) + if sd == cython.NULL: + raise MemoryError("could not allocate display matrix side data") + + memcpy(sd.data, self._display_matrix, n) + + def set_display_matrix(self, matrix): + """Set the display matrix written to the container as coded side data. + + ``matrix`` is a sequence of 9 integers in FFmpeg's ``AV_PKT_DATA_DISPLAYMATRIX`` + layout, or ``None`` to clear. Must be called before the first frame is + encoded. See :meth:`set_display_rotation` for a higher-level helper. + """ + if matrix is None: + self._has_display_matrix = False + return + + vals = [int(v) for v in matrix] + if len(vals) != 9: + raise ValueError("display matrix must have exactly 9 elements") + i: cython.int + for i in range(9): + self._display_matrix[i] = vals[i] + self._has_display_matrix = True + + def set_display_rotation(self, degrees, hflip=False, vflip=False): + """Set the container display matrix from a rotation and optional flips. + + ``degrees`` is a counter-clockwise rotation (matching the value read back + from :attr:`VideoFrame.rotation`); ``hflip`` / ``vflip`` mirror after it. + Together these express all eight EXIF orientations. Must be called before + the first frame is encoded. + """ + # av_display_rotation_set() takes a clockwise angle; negate so our public + # `degrees` is counter-clockwise, matching VideoFrame.rotation on read. + lib.av_display_rotation_set(self._display_matrix, -float(degrees)) + lib.av_display_matrix_flip(self._display_matrix, bool(hflip), bool(vflip)) + self._has_display_matrix = True + + @property + def average_rate(self): + """ + The average frame rate of this video stream. + + This is calculated when the file is opened by looking at the first + few frames and averaging their rate. + + :type: fractions.Fraction | None + """ + return avrational_to_fraction(cython.address(self.ptr.avg_frame_rate)) + + @property + def base_rate(self): + """ + The base frame rate of this stream. + + This is calculated as the lowest framerate at which the timestamps of + frames can be represented accurately. See :ffmpeg:`AVStream.r_frame_rate` + for more. + + :type: fractions.Fraction | None + """ + return avrational_to_fraction(cython.address(self.ptr.r_frame_rate)) + + @property + def guessed_rate(self): + """The guessed frame rate of this stream. + + This is a wrapper around :ffmpeg:`av_guess_frame_rate`, and uses multiple + heuristics to decide what is "the" frame rate. + + :type: fractions.Fraction | None + """ + val: lib.AVRational = lib.av_guess_frame_rate( + cython.NULL, self.ptr, cython.NULL + ) + return avrational_to_fraction(cython.address(val)) + + @property + def sample_aspect_ratio(self): + """The guessed sample aspect ratio (SAR) of this stream. + + This is a wrapper around :ffmpeg:`av_guess_sample_aspect_ratio`, and uses multiple + heuristics to decide what is "the" sample aspect ratio. + + :type: fractions.Fraction | None + """ + sar: lib.AVRational = lib.av_guess_sample_aspect_ratio( + self.container.ptr, self.ptr, cython.NULL + ) + return avrational_to_fraction(cython.address(sar)) + + @property + def display_aspect_ratio(self): + """The guessed display aspect ratio (DAR) of this stream. + + This is calculated from :meth:`.VideoStream.guessed_sample_aspect_ratio`. + + :type: fractions.Fraction | None + """ + dar = cython.declare(lib.AVRational) + lib.av_reduce( + cython.address(dar.num), + cython.address(dar.den), + self.format.width * self.sample_aspect_ratio.num, + self.format.height * self.sample_aspect_ratio.den, + 1024 * 1024, + ) + + return avrational_to_fraction(cython.address(dar)) diff --git a/av/video/stream.pyi b/av/video/stream.pyi new file mode 100644 index 000000000..e6797f413 --- /dev/null +++ b/av/video/stream.pyi @@ -0,0 +1,48 @@ +from collections.abc import Iterator, Sequence +from fractions import Fraction +from typing import Literal + +from av.codec.context import ThreadType +from av.packet import Packet +from av.stream import Stream + +from .codeccontext import VideoCodecContext +from .format import VideoFormat +from .frame import VideoFrame + +class VideoStream(Stream): + bit_rate: int | None + max_bit_rate: int | None + bit_rate_tolerance: int + sample_aspect_ratio: Fraction | None + display_aspect_ratio: Fraction | None + codec_context: VideoCodecContext + + def encode(self, frame: VideoFrame | None = None) -> list[Packet]: ... + def encode_lazy(self, frame: VideoFrame | None = None) -> Iterator[Packet]: ... + def decode(self, packet: Packet | None = None) -> list[VideoFrame]: ... + def set_display_matrix(self, matrix: Sequence[int] | None) -> None: ... + def set_display_rotation( + self, degrees: float, hflip: bool = ..., vflip: bool = ... + ) -> None: ... + + # from codec context + format: VideoFormat + thread_count: int + thread_type: ThreadType + width: int + height: int + bits_per_coded_sample: int + pix_fmt: str | None + framerate: Fraction + rate: Fraction + gop_size: int + has_b_frames: bool + max_b_frames: int + coded_width: int + coded_height: int + color_range: int + color_primaries: int + color_trc: int + colorspace: int + type: Literal["video"] diff --git a/av/video/stream.pyx b/av/video/stream.pyx deleted file mode 100644 index 94b3cf369..000000000 --- a/av/video/stream.pyx +++ /dev/null @@ -1,23 +0,0 @@ -from libc.stdint cimport int64_t -cimport libav as lib - -from av.container.core cimport Container -from av.utils cimport avrational_to_fraction - - -cdef class VideoStream(Stream): - - def __repr__(self): - return '' % ( - self.__class__.__name__, - self.index, - self.name, - self.format.name if self.format else None, - self._codec_context.width, - self._codec_context.height, - id(self), - ) - - property average_rate: - def __get__(self): - return avrational_to_fraction(&self._stream.avg_frame_rate) diff --git a/docs/Makefile b/docs/Makefile index 65a1e540e..e12e6f701 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,40 +1,32 @@ - SPHINXOPTS = -SPHINXBUILD = sphinx-build BUILDDIR = _build - +PYAV_PIP ?= pip +PIP := $(PYAV_PIP) ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(SPHINXOPTS) . .PHONY: clean html open upload default default: html - -TAGFILE := _build/doxygen/tagfile.xml -$(TAGFILE) : - ./generate-tagfile -o $(TAGFILE) - - TEMPLATES := $(wildcard api/*.py development/*.py) RENDERED := $(TEMPLATES:%.py=_build/rst/%.rst) + _build/rst/%.rst: %.py $(TAGFILE) $(shell find ../include ../av -name '*.pyx' -or -name '*.pxd') @ mkdir -p $(@D) python $< > $@.tmp mv $@.tmp $@ - -clean: - - rm -rf $(BUILDDIR)/* - -html: $(RENDERED) $(TAGFILE) - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html +html: $(RENDERED) + $(PIP) install -U sphinx sphinx-copybutton + rm -rf $(BUILDDIR) + sphinx-build -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html test: - PYAV_SKIP_DOXYLINK=1 $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + PYAV_SKIP_DOXYLINK=1 sphinx-build -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest open: open _build/html/index.html upload: - rsync -avxP --delete _build/html/ pyav.org:/srv/pyav.org/www/httpdocs/docs/develop/ + rsync -avxP --delete _build/html/ root@basswood.io:/var/www/pyav/docs/develop diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 1069c8f1d..136af619b 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,3 +1,10 @@ +body { + text-rendering: optimizeLegibility; +} + +.sphinxsidebarwrapper { + overflow: scroll; +} .ffmpeg-quicklink { float: right; @@ -12,3 +19,7 @@ .ffmpeg-quicklink:after { content: "]"; } + +.body a, .document a { + text-decoration: underline !important; +} diff --git a/docs/_static/logo-250.png b/docs/_static/logo-250.png deleted file mode 100644 index 32cb6bfa1..000000000 Binary files a/docs/_static/logo-250.png and /dev/null differ diff --git a/docs/_static/logo.webp b/docs/_static/logo.webp new file mode 100644 index 000000000..6ef7ad825 Binary files /dev/null and b/docs/_static/logo.webp differ diff --git a/docs/api/bitstream.rst b/docs/api/bitstream.rst new file mode 100644 index 000000000..4dab1bde5 --- /dev/null +++ b/docs/api/bitstream.rst @@ -0,0 +1,9 @@ + +Bitstream Filters +================= + +.. automodule:: av.bitstream + + .. autoclass:: BitStreamFilterContext + :members: + diff --git a/docs/api/codec.rst b/docs/api/codec.rst index 44fead746..a8340bcb2 100644 --- a/docs/api/codec.rst +++ b/docs/api/codec.rst @@ -12,10 +12,9 @@ Descriptors .. automethod:: Codec.create +.. autoattribute:: Codec.is_decoder .. autoattribute:: Codec.is_encoder -.. autoattribute:: Codec.is_encoder -.. - .. autoattribute:: Codec.descriptor + .. autoattribute:: Codec.name .. autoattribute:: Codec.long_name .. autoattribute:: Codec.type @@ -36,21 +35,26 @@ Flags Wraps :ffmpeg:`AVCodecDescriptor.props` (``AV_CODEC_PROP_*``). - .. enumtable:: av.codec.codec.Properties - :class: av.codec.codec.Codec - .. autoattribute:: Codec.capabilities .. autoclass:: Capabilities Wraps :ffmpeg:`AVCodec.capabilities` (``AV_CODEC_CAP_*``). - Note that ``ffmpeg -codecs`` prefers the properties versions of - ``INTRA_ONLY`` and ``LOSSLESS``. + Note that ``ffmpeg -codecs`` prefers the properties versions of ``INTRA_ONLY`` and ``LOSSLESS``. + +Pixel Format Selection +---------------------- + +.. autofunction:: find_best_pix_fmt_of_list - .. enumtable:: av.codec.codec.Capabilities - :class: av.codec.codec.Codec +.. autoclass:: PixFmtLoss + Wraps FFmpeg's ``FF_LOSS_*`` flags. Returned by + :func:`find_best_pix_fmt_of_list` to describe what is lost when converting + from the source pixel format to the chosen one. Being an + :class:`enum.IntFlag`, members can be combined and tested with bitwise + operators. Contexts -------- @@ -62,10 +66,16 @@ Contexts .. autoattribute:: CodecContext.codec .. autoattribute:: CodecContext.options +.. autoattribute:: CodecContext.supported_options + +.. autoclass:: CodecOptionSet +.. autoclass:: CodecOption +.. autoclass:: CodecOptionChoice +.. autoclass:: OptionType +.. autoclass:: OptionFlags .. automethod:: CodecContext.create .. automethod:: CodecContext.open -.. automethod:: CodecContext.close Attributes ~~~~~~~~~~ @@ -79,7 +89,6 @@ Attributes .. autoattribute:: CodecContext.profile .. autoattribute:: CodecContext.time_base -.. autoattribute:: CodecContext.ticks_per_frame .. autoattribute:: CodecContext.bit_rate .. autoattribute:: CodecContext.bit_rate_tolerance @@ -97,10 +106,11 @@ Transcoding .. automethod:: CodecContext.parse .. automethod:: CodecContext.encode .. automethod:: CodecContext.decode +.. automethod:: CodecContext.flush_buffers -Flags -~~~~~ +Enums and Flags +~~~~~~~~~~~~~~~ .. autoattribute:: CodecContext.flags @@ -116,10 +126,6 @@ Flags .. enumtable:: av.codec.context:Flags2 :class: av.codec.context:CodecContext - -Enums -~~~~~ - .. autoclass:: av.codec.context.ThreadType Which multithreading methods to use. @@ -128,8 +134,37 @@ Enums .. enumtable:: av.codec.context.ThreadType -.. autoclass:: av.codec.context.SkipType - .. enumtable:: av.codec.context.SkipType +Hardware Acceleration +--------------------- + +.. currentmodule:: av.codec.hwaccel +.. automodule:: av.codec.hwaccel + +.. autoclass:: HWAccel + +.. autofunction:: hwdevices_available + +To decode on a hardware device, pass an :class:`HWAccel` to :func:`av.open`:: + + import av + from av.codec.hwaccel import HWAccel + + hwaccel = HWAccel(device_type="videotoolbox") + with av.open("input.mp4", hwaccel=hwaccel) as container: + for frame in container.decode(video=0): + ... # Frames are downloaded to system memory by default. + +To encode on a hardware device, pass one to :meth:`OutputContainer.add_stream +` with a hardware encoder. Software +frames passed to ``encode`` are uploaded to the device automatically:: + + with av.open("output.mp4", "w") as container: + stream = container.add_stream( + "h264_videotoolbox", rate=30, hwaccel=HWAccel(device_type="videotoolbox") + ) + ... +See ``examples/basics/hw_decode.py`` for a complete example, including +recommended device types per platform. diff --git a/docs/api/container.rst b/docs/api/container.rst index f4c9732c9..71739fe3f 100644 --- a/docs/api/container.rst +++ b/docs/api/container.rst @@ -60,7 +60,6 @@ Formats .. autoattribute:: ContainerFormat.name .. autoattribute:: ContainerFormat.long_name -.. autoattribute:: ContainerFormat.options .. autoattribute:: ContainerFormat.input .. autoattribute:: ContainerFormat.output .. autoattribute:: ContainerFormat.is_input diff --git a/docs/api/enum.rst b/docs/api/enum.rst deleted file mode 100644 index 5fdcec4f7..000000000 --- a/docs/api/enum.rst +++ /dev/null @@ -1,24 +0,0 @@ - -Enumerations and Flags -====================== - -.. currentmodule:: av.enum - -.. automodule:: av.enum - - -.. _enums: - -Enumerations ------------- - -.. autoclass:: EnumItem - - -.. _flags: - -Flags ------ - -.. autoclass:: EnumFlag - diff --git a/docs/api/error.rst b/docs/api/error.rst index 3f82f4ec2..557ab188e 100644 --- a/docs/api/error.rst +++ b/docs/api/error.rst @@ -5,39 +5,18 @@ Errors .. _error_behaviour: -General Behaviour +General Behavior ----------------- When PyAV encounters an FFmpeg error, it raises an appropriate exception. FFmpeg has a couple dozen of its own error types which we represent via -:ref:`error_classes` and at a lower level via :ref:`error_types`. +:ref:`error_classes`. FFmpeg will also return more typical errors such as ``ENOENT`` or ``EAGAIN``, which we do our best to translate to extensions of the builtin exceptions as defined by -`PEP 3151 `_ -(and fall back onto ``OSError`` if using Python < 3.3). - - -.. _error_types: - -Error Type Enumerations ------------------------ - -We provide :class:`av.error.ErrorType` as an enumeration of the various FFmpeg errors. -To mimick the stdlib ``errno`` module, all enumeration values are available in -the ``av.error`` module, e.g.:: - - try: - do_something() - except OSError as e: - if e.errno != av.error.FILTER_NOT_FOUND: - raise - handle_error() - - -.. autoclass:: av.error.ErrorType +`PEP 3151 `_. .. _error_classes: @@ -55,7 +34,7 @@ There are two competing ideas that have influenced the final design: 2. We want to use the builtin exceptions whenever possible. -As such, PyAV effectivly shadows as much of the builtin exception heirarchy as +As such, PyAV effectively shadows as much of the builtin exception hierarchy as it requires, extending from both the builtins and from :class:`FFmpegError`. Therefore, an argument error within FFmpeg will raise a ``av.error.ValueError``, which @@ -74,12 +53,3 @@ All of these exceptions are available on the top-level ``av`` package, e.g.:: .. autoclass:: av.FFmpegError - -Mapping Codes and Classes -------------------------- - -Here is how the classes line up with the error codes/enumerations: - -.. include:: ../_build/rst/api/error_table.rst - - diff --git a/docs/api/error_table.py b/docs/api/error_table.py deleted file mode 100644 index e67b9f40b..000000000 --- a/docs/api/error_table.py +++ /dev/null @@ -1,37 +0,0 @@ - -import av - - -rows = [( - #'Tag (Code)', - 'Exception Class', - 'Code/Enum Name', - 'FFmpeg Error Message', -)] - -for code, cls in av.error.classes.items(): - - enum = av.error.ErrorType.get(code) - - if not enum: - continue - - if enum.tag == b'PyAV': - continue - - rows.append(( - #'{} ({})'.format(enum.tag, code), - '``av.{}``'.format(cls.__name__), - '``av.error.{}``'.format(enum.name), - enum.strerror, - )) - -lens = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))] - -header = tuple('=' * x for x in lens) -rows.insert(0, header) -rows.insert(2, header) -rows.append(header) - -for row in rows: - print(' '.join('{:{}s}'.format(cell, len_) for cell, len_ in zip(row, lens))) diff --git a/docs/api/filter.rst b/docs/api/filter.rst index d126fda67..8674fdd9e 100644 --- a/docs/api/filter.rst +++ b/docs/api/filter.rst @@ -24,11 +24,3 @@ Filters .. autoclass:: FilterLink :members: - -.. automodule:: av.filter.pad - - .. autoclass:: FilterPad - :members: - - .. autoclass:: FilterContextPad - :members: diff --git a/docs/api/stream.rst b/docs/api/stream.rst index bcfa64896..98d8fc43d 100644 --- a/docs/api/stream.rst +++ b/docs/api/stream.rst @@ -16,6 +16,8 @@ Dynamic Slicing .. automethod:: StreamContainer.get +.. automethod:: StreamContainer.best + Typed Collections ~~~~~~~~~~~~~~~~~ @@ -35,6 +37,10 @@ dynamic capabilities of :meth:`.get`: A tuple of :class:`SubtitleStream`. +.. attribute:: StreamContainer.attachments + + A tuple of :class:`AttachmentStream`. + .. attribute:: StreamContainer.data A tuple of :class:`DataStream`. @@ -65,14 +71,6 @@ Basics .. autoattribute:: Stream.index -Transcoding -~~~~~~~~~~~ - -.. automethod:: Stream.encode - -.. automethod:: Stream.decode - - Timing ~~~~~~ @@ -87,35 +85,14 @@ Timing .. autoattribute:: Stream.frames -.. _frame_rates: - -Frame Rates -........... - - -These attributes are different ways of calculating frame rates. - -Since containers don't need to explicitly identify a frame rate, nor -even have a static frame rate, these attributes are not guaranteed to be accurate. -You must experiment with them with your media to see which ones work for you for your purposes. - -Whenever possible, we advise that you use raw timing instead of frame rates. - -.. autoattribute:: Stream.average_rate - -.. autoattribute:: Stream.base_rate - -.. autoattribute:: Stream.guessed_rate - - Others ~~~~~~ -.. automethod:: Stream.seek - .. autoattribute:: Stream.profile .. autoattribute:: Stream.language +.. autoattribute:: Stream.discard + diff --git a/docs/api/subtitles.rst b/docs/api/subtitles.rst index 19d75621c..949896d6d 100644 --- a/docs/api/subtitles.rst +++ b/docs/api/subtitles.rst @@ -15,14 +15,11 @@ Subtitles .. autoclass:: Subtitle :members: - .. autoclass:: BitmapSubtitle - :members: - - .. autoclass:: BitmapSubtitlePlane + .. autoclass:: AssSubtitle :members: - .. autoclass:: TextSubtitle + .. autoclass:: BitmapSubtitle :members: - .. autoclass:: AssSubtitle + .. autoclass:: BitmapSubtitlePlane :members: diff --git a/docs/api/time.rst b/docs/api/time.rst index c0234e0f6..bb0a7dc5b 100644 --- a/docs/api/time.rst +++ b/docs/api/time.rst @@ -12,6 +12,7 @@ Time is expressed as integer multiples of arbitrary units of time called a ``tim .. testsetup:: import av + from fractions import Fraction path = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4') def get_nth_packet_and_frame(fh, skip): @@ -26,11 +27,16 @@ Time is expressed as integer multiples of arbitrary units of time called a ``tim >>> fh = av.open(path) >>> video = fh.streams.video[0] - >>> video.time_base - Fraction(1, 25) + >>> video.time_base == Fraction(1, 25) + True - >>> video.codec_context.time_base - Fraction(1, 50) +Rational attributes like ``time_base`` may be unset. Test them by truthiness rather than +``is None`` — an unset value is always falsy, both today (``None``) and as PyAV +transitions these attributes to :class:`av.AVRational` (where unset is the falsy +``AVRational(0, 1)``):: + + if not stream.time_base: + ... # unset; pick a default Attributes that represent time on those objects will be in that object's ``time_base``: @@ -41,7 +47,7 @@ Attributes that represent time on those objects will be in that object's ``time_ >>> float(video.duration * video.time_base) 6.72 -:class:`.Packet` has a :attr:`.Packet.pts` ("presentation" time stamp), and :class:`.Frame` has a :attr:`.Frame.pts` and :attr:`.Frame.dts` ("presentation" and "decode" time stamps). Both have a ``time_base`` attribute, but it defaults to the time base of the object that handles them. For packets that is streams. For frames it is streams when decoding, and codec contexts when encoding (which is strange, but it is what it is). +:class:`.Packet` has a :attr:`.Packet.pts` and :attr:`.Packet.dts` ("presentation" and "decode" time stamps), and :class:`.Frame` has a :attr:`.Frame.pts` ("presentation" time stamp). Both have a ``time_base`` attribute, but it defaults to the time base of the object that handles them. For packets that is streams. For frames it is streams when decoding, and codec contexts when encoding (which is strange, but it is what it is). In many cases a stream has a time base of ``1 / frame_rate``, and then its frames have incrementing integers for times (0, 1, 2, etc.). Those frames take place at ``pts * time_base`` or ``0 / frame_rate``, ``1 / frame_rate``, ``2 / frame_rate``, etc.. @@ -49,18 +55,18 @@ In many cases a stream has a time base of ``1 / frame_rate``, and then its frame >>> p, f = get_nth_packet_and_frame(fh, skip=1) - >>> p.time_base - Fraction(1, 25) + >>> p.time_base == Fraction(1, 25) + True >>> p.dts 1 - >>> f.time_base - Fraction(1, 25) + >>> f.time_base == Fraction(1, 25) + True >>> f.pts 1 -For convenince, :attr:`.Frame.time` is a ``float`` in seconds: +For convenience, :attr:`.Frame.time` is a ``float`` in seconds: .. doctest:: @@ -68,10 +74,10 @@ For convenince, :attr:`.Frame.time` is a ``float`` in seconds: 0.04 -FFMpeg Internals +FFmpeg Internals ---------------- -.. note:: Time in FFmpeg is not 100% clear to us (see :ref:`authority_of_docs`). At times the FFmpeg documentation and canonical seeming posts in the forums appear contradictory. We've experiemented with it, and what follows is the picture that we are operating under. +.. note:: Time in FFmpeg is not 100% clear to us (see :ref:`authority_of_docs`). At times the FFmpeg documentation and canonical seeming posts in the forums appear contradictory. We've experimented with it, and what follows is the picture that we are operating under. Both :ffmpeg:`AVStream` and :ffmpeg:`AVCodecContext` have a ``time_base`` member. However, they are used for different purposes, and (this author finds) it is too easy to abstract the concept too far. @@ -85,9 +91,6 @@ For encoding, you (the PyAV developer / FFmpeg "user") must set :ffmpeg:`AVCodec You then prepare :ffmpeg:`AVFrame.pts` in :ffmpeg:`AVCodecContext.time_base`. The encoded :ffmpeg:`AVPacket.pts` is simply copied from the frame by the library, and so is still in the codec's time base. You must rescale it to :ffmpeg:`AVStream.time_base` before muxing (as all stream operations assume the packet time is in stream time base). -For fixed-fps content your frames' ``pts`` would be the frame or sample index (for video and audio, respectively). PyAV should attempt to do this. - - Decoding ........ diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 820f30f0a..ecc1c6faa 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -14,5 +14,3 @@ Other .. automodule:: av.utils :members: - - .. autoclass:: AVError diff --git a/docs/api/video.rst b/docs/api/video.rst index 5e47b1db8..8d349fc1b 100644 --- a/docs/api/video.rst +++ b/docs/api/video.rst @@ -33,6 +33,9 @@ Video Frames .. automodule:: av.video.frame +.. autoclass:: CudaContext + :members: + .. autoclass:: VideoFrame A single video frame. @@ -80,6 +83,7 @@ Conversions .. automethod:: VideoFrame.from_image .. automethod:: VideoFrame.from_ndarray +.. automethod:: VideoFrame.from_dlpack @@ -113,7 +117,13 @@ Enums .. autoclass:: av.video.reformatter.Colorspace Wraps the ``SWS_CS_*`` flags. There is a bit of overlap in - these names which comes from FFmpeg and backards compatibility. + these names which comes from FFmpeg and backwards compatibility. .. enumtable:: av.video.reformatter.Colorspace +.. autoclass:: av.video.reformatter.ColorRange + + Wraps the ``AVCOL*`` flags. + + .. enumtable:: av.video.reformatter.ColorRange + diff --git a/docs/conf.py b/docs/conf.py index 795627b83..b551eb08e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,203 +1,70 @@ -# -*- coding: utf-8 -*- -# -# PyAV documentation build configuration file, created by -# sphinx-quickstart on Fri Dec 7 22:13:16 2012. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -from docutils import nodes -import logging -import math import os -import re -import sys import sys -import xml.etree.ElementTree as etree -import sphinx -from sphinx import addnodes +from docutils import nodes from sphinx.util.docutils import SphinxDirective +sys.path.insert(0, os.path.abspath("..")) -logging.basicConfig() - - -if sphinx.version_info < (1, 8): - print("Sphinx {} is too old; we require >= 1.8.".format(sphinx.__version__), file=sys.stderr) - exit(1) - - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('..')) - -# -- 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.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx.ext.extlinks', - 'sphinx.ext.doctest', - - # We used to use doxylink, but we found its caching behaviour annoying, and - # so made a minimally viable version of our own. + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx.ext.extlinks", + "sphinx.ext.doctest", + "sphinx_copybutton", # Add copy button to code blocks ] - # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'PyAV' -copyright = u'2017, Mike Boers' +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" +project = "PyAV" +copyright = "2026, The PyAV Team" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. -# -# The full version, including alpha/beta/rc tags. -release = open('../VERSION.txt').read().strip() - -# The short X.Y version. -version = release.split('-')[0] - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +about = {} +with open("../av/about.py") as fp: + exec(fp.read(), about) +release = about["__version__"] +version = release.split("-")[0] +exclude_patterns = ["_build"] +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 = 'pyav' -html_theme_path = [os.path.abspath(os.path.join(__file__, '..', '_themes'))] -# 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 themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_theme = "pyav" +html_theme_path = [os.path.abspath(os.path.join(__file__, "..", "_themes"))] # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = '_static/logo-250.png' +html_logo = "_static/logo.webp" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -html_favicon = '_static/favicon.png' +html_favicon = "_static/favicon.png" # 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'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +html_static_path = ["_static"] -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +html_theme_options = { + "sidebarwidth": "250px", +} -doctest_global_setup = ''' +doctest_global_setup = """ import errno import os @@ -221,34 +88,23 @@ def sandboxed(*args, **kwargs): os.chdir(here) video_path = curated('pexels/time-lapse-video-of-night-sky-857195.mp4') +""" -''' - -doctest_global_cleanup = ''' - -os.chdir(_cwd) - -''' - - -doctest_test_doctest_blocks = '' - +doctest_global_cleanup = "os.chdir(_cwd)" +doctest_test_doctest_blocks = "" extlinks = { - 'ffstruct': ('http://ffmpeg.org/doxygen/trunk/struct%s.html', 'struct '), - 'issue': ('https://github.com/PyAV-Org/PyAV/issues/%s', '#'), - 'pr': ('https://github.com/PyAV-Org/PyAV/pull/%s', '#'), - 'gh-user': ('https://github.com/%s', '@'), + "issue": ("https://github.com/PyAV-Org/PyAV/issues/%s", "#%s"), + "pr": ("https://github.com/PyAV-Org/PyAV/pull/%s", "#%s"), + "gh-user": ("https://github.com/%s", "@%s"), } -intersphinx_mapping = { - 'https://docs.python.org/3': None, -} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} -autodoc_member_order = 'bysource' +autodoc_member_order = "bysource" autodoc_default_options = { - 'undoc-members': True, - 'show-inheritance': True, + "undoc-members": True, + "show-inheritance": True, } @@ -256,57 +112,54 @@ def sandboxed(*args, **kwargs): class PyInclude(SphinxDirective): - has_content = True def run(self): - - - source = '\n'.join(self.content) + source = "\n".join(self.content) output = [] - def write(*content, sep=' ', end='\n'): + + def write(*content, sep=" ", end="\n"): output.append(sep.join(map(str, content)) + end) namespace = dict(write=write) - exec(compile(source, '', 'exec'), namespace, namespace) + exec(compile(source, "", "exec"), namespace, namespace) - output = ''.join(output).splitlines() - self.state_machine.insert_input(output, 'blah') + output = "".join(output).splitlines() + self.state_machine.insert_input(output, "blah") - return [] #[nodes.literal('hello', repr(content))] + return [] # [nodes.literal('hello', repr(content))] def load_entrypoint(name): - - parts = name.split(':') + parts = name.split(":") if len(parts) == 1: - parts = name.rsplit('.', 1) + parts = name.rsplit(".", 1) mod_name, attrs = parts - attrs = attrs.split('.') + attrs = attrs.split(".") try: - obj = __import__(mod_name, fromlist=['.']) + obj = __import__(mod_name, fromlist=["."]) except ImportError as e: - print('Error while importing.', (name, mod_name, attrs, e)) + print("Error while importing.", (name, mod_name, attrs, e)) raise + for attr in attrs: obj = getattr(obj, attr) + return obj -class EnumTable(SphinxDirective): +class EnumTable(SphinxDirective): required_arguments = 1 option_spec = { - 'class': lambda x: x, + "class": lambda x: x, } def run(self): - - cls_ep = self.options.get('class') + cls_ep = self.options.get("class") cls = load_entrypoint(cls_ep) if cls_ep else None enum = load_entrypoint(self.arguments[0]) - properties = {} if cls is not None: @@ -342,20 +195,21 @@ def makerow(*texts): for text in texts: if text is None: continue - row += nodes.entry('', nodes.paragraph('', str(text))) + row += nodes.entry("", nodes.paragraph("", str(text))) return row thead += makerow( - '{} Attribute'.format(cls.__name__) if cls else None, - '{} Name'.format(enum.__name__), - 'Flag Value', - 'Meaning in FFmpeg', + f"{cls.__name__} Attribute" if cls else None, + f"{enum.__name__} Name", + "Flag Value", + "Meaning in FFmpeg", ) seen = set() - - for name, item in enum._by_name.items(): - + enum_items = [ + (name, item) for name, item in vars(enum).items() if isinstance(item, enum) + ] + for name, item in enum_items: if name.lower() in seen: continue seen.add(name.lower()) @@ -367,128 +221,67 @@ def makerow(*texts): continue attr = None - value = '0x{:X}'.format(item.value) - - doc = item.__doc__ or '-' - - tbody += makerow( - attr, - name, - value, - doc, - ) + value = f"0x{item.value:X}" + doc = enum.__annotations__.get(name, "---")[1:-1] + tbody += makerow(attr, name, value, doc) return [table] +def ffmpeg_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """ + Custom role for FFmpeg API links. + Converts :ffmpeg:`AVSomething` into proper FFmpeg API documentation links. + """ + base_url = "https://ffmpeg.org/doxygen/8.0/struct{}.html" -doxylink = {} -ffmpeg_tagfile = os.path.abspath(os.path.join(__file__, '..', '_build', 'doxygen', 'tagfile.xml')) -if not os.path.exists(ffmpeg_tagfile): - print("ERROR: Missing FFmpeg tagfile.") - exit(1) -doxylink['ffmpeg'] = (ffmpeg_tagfile, 'https://ffmpeg.org/doxygen/trunk/') - - -def doxylink_create_handler(app, file_name, url_base): - - print("Finding all names in Doxygen tagfile", file_name) - - doc = etree.parse(file_name) - root = doc.getroot() - - parent_map = {} # ElementTree doesn't five us access to parents. - urls = {} - - for node in root.findall('.//name/..'): - - for child in node: - parent_map[child] = node - - kind = node.attrib['kind'] - if kind not in ('function', 'struct', 'variable'): - continue - - name = node.find('name').text - - if kind not in ('function', ): - parent = parent_map.get(node) - parent_name = parent.find('name') if parent else None - if parent_name is not None: - name = '{}.{}'.format(parent_name.text, name) - - filenode = node.find('filename') - if filenode is not None: - url = filenode.text - else: - url = '{}#{}'.format( - node.find('anchorfile').text, - node.find('anchor').text, - ) - - urls.setdefault(kind, {})[name] = url - - def get_url(name): - # These are all the kinds that seem to exist. - for kind in ( - 'function', - 'struct', - 'variable', # These are struct members. - # 'class', - # 'define', - # 'enumeration', - # 'enumvalue', - # 'file', - # 'group', - # 'page', - # 'typedef', - # 'union', - ): - try: - return urls[kind][name] - except KeyError: - pass - - - def _doxylink_handler(name, rawtext, text, lineno, inliner, options={}, content=[]): - - m = re.match(r'^(.+?)(?:<(.+?)>)?$', text) - title, name = m.groups() - name = name or title - - url = get_url(name) - if not url: - print("ERROR: Could not find", name) - exit(1) - - node = addnodes.literal_strong(title, title) - if url: - url = url_base + url - node = nodes.reference( - '', '', node, refuri=url - ) - - return [node], [] - - return _doxylink_handler - - + try: + struct_name, member = text.split(".") + except Exception: + struct_name = None + + if struct_name is None: + fragment = { + "avformat_seek_file": "group__lavf__decoding.html#ga3b40fc8d2fda6992ae6ea2567d71ba30", + "av_find_best_stream": "avformat_8c.html#a8d4609a8f685ad894c1503ffd1b610b4", + "av_frame_make_writable": "group__lavu__frame.html#gadd5417c06f5a6b419b0dbd8f0ff363fd", + "avformat_write_header": "group__lavf__encoding.html#ga18b7b10bb5b94c4842de18166bc677cb", + "av_guess_frame_rate": "group__lavf__misc.html#ga698e6aa73caa9616851092e2be15875d", + "av_guess_sample_aspect_ratio": "group__lavf__misc.html#gafa6fbfe5c1bf6792fd6e33475b6056bd", + }.get(text, f"struct{text}.html") + url = "https://ffmpeg.org/doxygen/8.0/" + fragment + else: + fragment = { + "AVCodecContext.thread_count": "#aa852b6227d0778b62e9cc4034ad3720c", + "AVCodecContext.thread_type": "#a7651614f4309122981d70e06a4b42fcb", + "AVCodecContext.skip_frame": "#af869b808363998c80adf7df6a944a5a6", + "AVCodecContext.qmin": "#a3f63bc9141e25bf7f1cda0cef7cd4a60", + "AVCodecContext.qmax": "#ab015db3b7fcd227193a7c17283914187", + "AVCodec.capabilities": "#af51f7ff3dac8b730f46b9713e49a2518", + "AVCodecDescriptor.props": "#a9949288403a12812cd6e3892ac45f40f", + "AVCodecContext.bits_per_coded_sample": "#a3866500f51fabfa90faeae894c6e955c", + "AVFrame.color_range": "#a853afbad220bbc58549b4860732a3aa5", + "AVFrame.color_primaries": "#a59a3f830494f2ed1133103a1bc9481e7", + "AVFrame.color_trc": "#ab09abb126e3922bc1d010cf044087939", + "AVFrame.colorspace": "#a9262c231f1f64869439b4fe587fe1710", + }.get(text, f"#{member}") + + url = base_url.format(struct_name) + fragment + + node = nodes.reference(rawtext, text, refuri=url, **options) + return [node], [] def setup(app): - - app.add_stylesheet('custom.css') - - app.add_directive('flagtable', EnumTable) - app.add_directive('enumtable', EnumTable) - app.add_directive('pyinclude', PyInclude) - - skip = os.environ.get('PYAV_SKIP_DOXYLINK') - for role, (filename, url_base) in doxylink.items(): - if skip: - app.add_role(role, lambda *args: ([], [])) - else: - app.add_role(role, doxylink_create_handler(app, filename, url_base)) - - + app.add_css_file("custom.css") + app.add_role("ffmpeg", ffmpeg_role) + app.add_directive("flagtable", EnumTable) + app.add_directive("enumtable", EnumTable) + app.add_directive("pyinclude", PyInclude) + + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/cookbook/audio.rst b/docs/cookbook/audio.rst new file mode 100644 index 000000000..90d6bf0da --- /dev/null +++ b/docs/cookbook/audio.rst @@ -0,0 +1,10 @@ +Audio +===== + + +Filters +------- + +Increase the audio speed by applying the atempo filter. The speed is increased by 2. + +.. literalinclude:: ../../examples/audio/atempo.py diff --git a/docs/cookbook/basics.rst b/docs/cookbook/basics.rst index 2896519de..d7c1d6320 100644 --- a/docs/cookbook/basics.rst +++ b/docs/cookbook/basics.rst @@ -15,7 +15,7 @@ If you just want to look at keyframes, you can set :attr:`.CodecContext.skip_fra Remuxing -------- -Remuxing is copying audio/video data from one container to the other without transcoding it. By doing so, the data does not suffer any generational loss, and is the full quality that it was in the source container. +Remuxing is copying audio/video data from one container to the other without transcoding. By doing so, the data does not suffer any generational loss, and is the full quality that it was in the source container. .. literalinclude:: ../../examples/basics/remux.py @@ -40,3 +40,16 @@ Also enabling :data:`~av.codec.context.ThreadType.FRAME` (or :data:`~av.codec.co .. literalinclude:: ../../examples/basics/thread_type.py On the author's machine, the second pass decodes ~5 times faster. + + +Recording the Screen +-------------------- + +.. literalinclude:: ../../examples/basics/record_screen.py + + +Recording a Facecam +------------------- + +.. literalinclude:: ../../examples/basics/record_facecam.py + diff --git a/docs/cookbook/numpy.rst b/docs/cookbook/numpy.rst index d4887945c..8b97e4ecb 100644 --- a/docs/cookbook/numpy.rst +++ b/docs/cookbook/numpy.rst @@ -1,4 +1,4 @@ -Numpy +NumPy ===== @@ -7,7 +7,7 @@ Video Barcode A video barcode shows the change in colour and tone over time. Time is represented on the horizontal axis, while the vertical remains the vertical direction in the image. -See http://moviebarcode.tumblr.com/ for examples from Hollywood movies, and here is an example from a sunset timelapse: +See https://moviebarcode.tumblr.com/ for examples from Hollywood movies, and here is an example from a sunset timelapse: .. image:: ../_static/examples/numpy/barcode.jpg @@ -21,4 +21,3 @@ Generating Video .. literalinclude:: ../../examples/numpy/generate_video.py - diff --git a/docs/cookbook/subtitles.rst b/docs/cookbook/subtitles.rst new file mode 100644 index 000000000..e3ca79d5d --- /dev/null +++ b/docs/cookbook/subtitles.rst @@ -0,0 +1,10 @@ +Subtitles +========= + + +Remuxing +-------- + +Remuxing is copying stream(s) from one container to the other without transcoding it. By doing so, the data does not suffer any generational loss, and is the full quality that it was in the source container. + +.. literalinclude:: ../../examples/subtitles/remux.py diff --git a/docs/development/hacking.rst b/docs/development/hacking.rst deleted file mode 100644 index 72e61e963..000000000 --- a/docs/development/hacking.rst +++ /dev/null @@ -1,6 +0,0 @@ - -.. It is all in the other file (that we want at the top-level of the repo). - -.. _hacking: - -.. include:: ../../HACKING.rst diff --git a/docs/development/includes.py b/docs/development/includes.py deleted file mode 100644 index b60629fda..000000000 --- a/docs/development/includes.py +++ /dev/null @@ -1,358 +0,0 @@ -import json -import os -import re -import sys - -import xml.etree.ElementTree as etree - -from Cython.Compiler.Main import compile_single, CompilationOptions -from Cython.Compiler.TreeFragment import parse_from_strings -from Cython.Compiler.Visitor import TreeVisitor -from Cython.Compiler import Nodes - -os.chdir(os.path.abspath(os.path.join(__file__, '..', '..', '..'))) - - -class Visitor(TreeVisitor): - - def __init__(self, state=None): - super(Visitor, self).__init__() - self.state = dict(state or {}) - self.events = [] - - def record_event(self, node, **kw): - state = self.state.copy() - state.update(**kw) - state['node'] = node - state['pos'] = node.pos - state['end_pos'] = node.end_pos() - self.events.append(state) - - def visit_Node(self, node): - self.visitchildren(node) - - def visit_ModuleNode(self, node): - self.state['module'] = node.full_module_name - self.visitchildren(node) - self.state.pop('module') - - def visit_CDefExternNode(self, node): - self.state['extern_from'] = node.include_file - self.visitchildren(node) - self.state.pop('extern_from') - - def visit_CStructOrUnionDefNode(self, node): - self.record_event(node, type='struct', name=node.name) - self.state['struct'] = node.name - self.visitchildren(node) - self.state.pop('struct') - - def visit_CFuncDeclaratorNode(self, node): - if isinstance(node.base, Nodes.CNameDeclaratorNode): - self.record_event(node, type='function', name=node.base.name) - else: - self.visitchildren(node) - - def visit_CVarDefNode(self, node): - - if isinstance(node.declarators[0], Nodes.CNameDeclaratorNode): - - # Grab the type name. - # TODO: Do a better job. - type_ = node.base_type - if hasattr(type_, 'name'): - type_name = type_.name - elif hasattr(type_, 'base_type'): - type_name = type_.base_type.name - else: - type_name = str(type_) - - self.record_event(node, type='variable', name=node.declarators[0].name, - vartype=type_name) - - else: - self.visitchildren(node) - - def visit_CClassDefNode(self, node): - self.state['class'] = node.class_name - self.visitchildren(node) - self.state.pop('class') - - def visit_PropertyNode(self, node): - self.state['property'] = node.name - self.visitchildren(node) - self.state.pop('property') - - def visit_DefNode(self, node): - self.state['function'] = node.name - self.visitchildren(node) - self.state.pop('function') - - def visit_AttributeNode(self, node): - if getattr(node.obj, 'name', None) == 'lib': - self.record_event(node, type='use', name=node.attribute) - else: - self.visitchildren(node) - - -def extract(path, **kwargs): - - name = os.path.splitext(os.path.relpath(path))[0].replace('/', '.') - - options = CompilationOptions() - options.include_path.append('include') - options.language_level = 2 - options.compiler_directives = dict( - c_string_type='str', - c_string_encoding='ascii', - ) - - context = options.create_context() - - tree = parse_from_strings(name, open(path).read(), context, - level='module_pxd' if path.endswith('.pxd') else None, - **kwargs) - - extractor = Visitor({'file': path}) - extractor.visit(tree) - return extractor.events - - -def iter_cython(path): - '''Yield all ``.pyx`` and ``.pxd`` files in the given root.''' - for dir_path, dir_names, file_names in os.walk(path): - for file_name in file_names: - if file_name.startswith('.'): - continue - if os.path.splitext(file_name)[1] not in ('.pyx', '.pxd'): - continue - yield os.path.join(dir_path, file_name) - - -doxygen = {} -doxygen_base = 'https://ffmpeg.org/doxygen/trunk' -tagfile_path = 'docs/_build/doxygen/tagfile.xml' - -tagfile_json = tagfile_path + '.json' -if os.path.exists(tagfile_json): - print('Loading pre-parsed Doxygen tagfile:', tagfile_json, file=sys.stderr) - doxygen = json.load(open(tagfile_json)) - - -if not doxygen: - - print('Parsing Doxygen tagfile:', tagfile_path, file=sys.stderr) - if not os.path.exists(tagfile_path): - print(' MISSING!', file=sys.stderr) - else: - - root = etree.parse(tagfile_path) - - def inspect_member(node, name_prefix=''): - name = name_prefix + node.find('name').text - anchorfile = node.find('anchorfile').text - anchor = node.find('anchor').text - - url = '%s/%s#%s' % (doxygen_base, anchorfile, anchor) - - doxygen[name] = {'url': url} - - if node.attrib['kind'] == 'function': - ret_type = node.find('type').text - arglist = node.find('arglist').text - sig = '%s %s%s' % (ret_type, name, arglist) - doxygen[name]['sig'] = sig - - for struct in root.iter('compound'): - if struct.attrib['kind'] != 'struct': - continue - name_prefix = struct.find('name').text + '.' - for node in struct.iter('member'): - inspect_member(node, name_prefix) - - for node in root.iter('member'): - inspect_member(node) - - - json.dump(doxygen, open(tagfile_json, 'w'), sort_keys=True, indent=4) - - -print('Parsing Cython source for references...', file=sys.stderr) -lib_references = {} -for path in iter_cython('av'): - try: - events = extract(path) - except Exception as e: - print(" %s in %s" % (e.__class__.__name__, path), file=sys.stderr) - print(" %s" % e, file=sys.stderr) - continue - for event in events: - if event['type'] == 'use': - lib_references.setdefault(event['name'], []).append(event) - - - - - - - -defs_by_extern = {} -for path in iter_cython('include'): - - # This one has "include" directives, which is not supported when - # parsing from a string. - if path == 'include/libav.pxd': - continue - - # Extract all #: comments from the source files. - comments_by_line = {} - for i, line in enumerate(open(path)): - m = re.match(r'^\s*#: ?', line) - if m: - comment = line[m.end():].rstrip() - comments_by_line[i + 1] = line[m.end():] - - # Extract Cython definitions from the source files. - for event in extract(path): - - extern = event.get('extern_from') or path.replace('include/', '') - defs_by_extern.setdefault(extern, []).append(event) - - # Collect comments above and below - comments = event['_comments'] = [] - line = event['pos'][1] - 1 - while line in comments_by_line: - comments.insert(0, comments_by_line.pop(line)) - line -= 1 - line = event['end_pos'][1] + 1 - while line in comments_by_line: - comments.append(comments_by_line.pop(line)) - line += 1 - - # Figure out the Sphinx headline. - if event['type'] == 'function': - event['_sort_key'] = 2 - sig = doxygen.get(event['name'], {}).get('sig') - if sig: - sig = re.sub(r'\).+', ')', sig) # strip trailer - event['_headline'] = '.. c:function:: %s' % sig - else: - event['_headline'] = '.. c:function:: %s()' % event['name'] - - elif event['type'] == 'variable': - struct = event.get('struct') - if struct: - event['_headline'] = '.. c:member:: %s %s' % (event['vartype'], event['name']) - event['_sort_key'] = 1.1 - else: - event['_headline'] = '.. c:var:: %s' % event['name'] - event['_sort_key'] = 3 - - elif event['type'] == 'struct': - event['_headline'] = '.. c:type:: struct %s' % event['name'] - event['_sort_key'] = 1 - event['_doxygen_url'] = '%s/struct%s.html' % (doxygen_base, event['name']) - - else: - print('Unknown event type %s' % event['type'], file=sys.stderr) - - name = event['name'] - if event.get('struct'): - name = '%s.%s' % (event['struct'], name) - - # Doxygen URLs - event.setdefault('_doxygen_url', doxygen.get(name, {}).get('url')) - - # Find use references. - ref_events = lib_references.get(name, []) - if ref_events: - - ref_pairs = [] - for ref in sorted(ref_events, key=lambda e: e['name']): - - chunks = [ - ref.get('module'), - ref.get('class'), - ] - chunks = filter(None, chunks) - prefix = '.'.join(chunks) + '.' if chunks else '' - - if ref.get('property'): - ref_pairs.append((ref['property'], ':attr:`%s%s`' % (prefix, ref['property']))) - elif ref.get('function'): - name = ref['function'] - if name in ('__init__', '__cinit__', '__dealloc__'): - ref_pairs.append((name, ':class:`%s%s <%s>`' % (prefix, name, prefix.rstrip('.')))) - else: - ref_pairs.append((name, ':func:`%s%s`' % (prefix, name))) - else: - continue - - unique_refs = event['_references'] = [] - seen = set() - for name, ref in sorted(ref_pairs): - if name in seen: - continue - seen.add(name) - unique_refs.append(ref) - - - - -print(''' - -.. - This file is generated by includes.py; any modifications will be destroyed! - -Wrapped C Types and Functions -============================= - -''') - -for extern, events in sorted(defs_by_extern.items()): - did_header = False - - for event in events: - - headline = event.get('_headline') - comments = event.get('_comments') - refs = event.get('_references', []) - url = event.get('_doxygen_url') - indent = ' ' if event.get('struct') else '' - - if not headline: - continue - if ( - not filter(None, (x.strip() for x in comments if x.strip())) and - not refs and - event['type'] not in ('struct', ) - ): - pass - - if not did_header: - print('``%s``' % extern) - print('-' * (len(extern) + 4)) - print() - did_header = True - - if url: - print() - print(indent + '.. rst-class:: ffmpeg-quicklink') - print() - print(indent + ' `FFmpeg Docs <%s>`__' % url) - - print(indent + headline) - print() - - if comments: - for line in comments: - print(indent + ' ' + line) - print() - - if refs: - print(indent + ' Referenced by: ', end='') - for i, ref in enumerate(refs): - print((', ' if i else '') + ref, end='') - print('.') - - print() diff --git a/docs/development/includes.rst b/docs/development/includes.rst deleted file mode 100644 index 6b2c989cb..000000000 --- a/docs/development/includes.rst +++ /dev/null @@ -1,2 +0,0 @@ - -.. include:: ../_build/rst/development/includes.rst diff --git a/docs/development/license.rst b/docs/development/license.rst index 57cd288f4..e49e1d435 100644 --- a/docs/development/license.rst +++ b/docs/development/license.rst @@ -6,7 +6,7 @@ License ======= -From `LICENSE.txt `_: +From `LICENSE.txt `_: .. literalinclude:: ../../LICENSE.txt :language: text diff --git a/docs/generate-tagfile b/docs/generate-tagfile deleted file mode 100755 index f00ed7ad4..000000000 --- a/docs/generate-tagfile +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python - -import os -import subprocess -import argparse - - -parser = argparse.ArgumentParser() -parser.add_argument('-l', '--library', default=os.environ.get('PYAV_LIBRARY')) -parser.add_argument('-o', '--output', default=os.path.abspath(os.path.join( - __file__, - '..', - '_build', - 'doxygen', - 'tagfile.xml', -))) -args = parser.parse_args() - - -if not args.library: - print("Please provide --library or set $PYAV_LIBRARY") - exit(1) - -library = os.path.abspath(os.path.join( - __file__, - '..', '..', - 'vendor', - args.library, -)) - -if not os.path.exists(library): - print("Library does not exist:", library) - exit(2) - - -output = os.path.abspath(args.output) -outdir = os.path.dirname(output) -if not os.path.exists(outdir): - os.makedirs(outdir) - - -proc = subprocess.Popen(['doxygen', '-'], stdin=subprocess.PIPE, cwd=library) -proc.communicate(''' - -#@INCLUDE = doc/Doxyfile -GENERATE_TAGFILE = {} -GENERATE_HTML = no -GENERATE_LATEX = no -CASE_SENSE_NAMES = yes -INPUT = libavcodec libavdevice libavfilter libavformat libavresample libavutil libswresample libswscale - -'''.format(output).encode()) - - diff --git a/docs/index.rst b/docs/index.rst index 386e819bd..5197232b3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,7 @@ **PyAV** is a Pythonic binding for FFmpeg_. We aim to provide all of the power and control of the underlying library, but manage the gritty details as much as possible. -PyAV is for direct and precise access to your media via containers, streams, packets, codecs, and frames. It exposes a few transformations of that data, and helps you get your data to/from other packages (e.g. Numpy and Pillow). +PyAV is for direct and precise access to your media via containers, streams, packets, codecs, and frames. It exposes a few transformations of that data, and helps you get your data to/from other packages (e.g. NumPy and Pillow). This power does come with some responsibility as working with media is horrendously complicated and PyAV can't abstract it away or make all the best decisions for you. If the ``ffmpeg`` command does the job without you bending over backwards, PyAV is likely going to be more of a hindrance than a help. @@ -21,6 +21,7 @@ Currently we provide: - ``libavcodec``: :class:`.Codec`, :class:`.CodecContext`, + :class:`.BitStreamFilterContext`, audio/video :class:`frames <.Frame>`, :class:`data planes <.Plane>`, :class:`subtitles <.Subtitle>`; @@ -52,10 +53,11 @@ Basic Demo import av + av.logging.set_level(av.logging.VERBOSE) container = av.open(path_to_video) - for frame in container.decode(video=0): - frame.to_image().save('frame-%04d.jpg' % frame.index) + for index, frame in enumerate(container.decode(video=0)): + frame.to_image().save(f"frame-{index:04d}.jpg") Overview diff --git a/docs/overview/about.rst b/docs/overview/about.rst deleted file mode 100644 index cfda991c1..000000000 --- a/docs/overview/about.rst +++ /dev/null @@ -1,72 +0,0 @@ -More About PyAV -=============== - -Binary wheels -------------- - -Since release 8.0.0 binary wheels are provided on PyPI for Linux, Mac and Windows linked against FFmpeg. Currently FFmpeg 4.2.2 is used with the following features enabled for all platforms: - -- fontconfig -- libaom -- libass -- libbluray -- libdav1d -- libfreetype -- libmp3lame -- libopencore-amrnb -- libopencore-amrwb -- libopenjpeg -- libopus -- libspeex -- libtheora -- libtwolame -- libvorbis -- libwavpack -- libx264 -- libx265 -- libxml2 -- libxvid -- lzma -- zlib - -Bring your own FFmpeg ---------------------- - -PyAV can also be compiled against your own build of FFmpeg. While it must be built for the specific FFmpeg version installed it does not require a specific version. You can force installing PyAV from source by running: - -``` -pip install av --no-binary av -``` - -We automatically detect the differences that we depended on at build time. This is a fairly trial-and-error process, so please let us know if something won't compile due to missing functions or members. - -Additionally, we are far from wrapping the full extents of the libraries. There are many functions and C struct members which are currently unexposed. - - -Dropping Libav --------------- - -Until mid-2018 PyAV supported either FFmpeg_ or Libav_. The split support in the community essentially required we do so. That split has largely been resolved as distributions have returned to shipping FFmpeg instead of Libav. - -While we could have theoretically continued to support both, it has been years since automated testing of PyAV with Libav passed, and we received zero complaints. Supporting both also restricted us to using the subset of both, which was starting to erode at the cleanliness of PyAV. - -Many Libav-isms remain in PyAV, and we will slowly scrub them out to clean up PyAV as we come across them again. - - -Unsupported Features --------------------- - -Our goal is to provide all of the features that make sense for the contexts that PyAV would be used in. If there is something missing, please reach out on Gitter_ or open a feature request on GitHub_ (or even better a pull request). Your request will be more likely to be addressed if you can point to the relevant `FFmpeg API documentation `__. - -There are some features we may elect to not implement because we don't believe they fit the PyAV ethos. The only one that we've encountered so far is hardware decoding. The `FFmpeg man page `__ discusses the drawback of ``-hwaccel``: - - Note that most acceleration methods are intended for playback and will not be faster than software decoding on modern CPUs. Additionally, ``ffmpeg`` will usually need to copy the decoded frames from the GPU memory into the system memory, resulting in further performance loss. - -Since PyAV is not expected to be used in a high performance playback loop, we do not find the added code complexity worth the benefits of supporting this feature - - -.. _FFmpeg: https://ffmpeg.org/ -.. _Libav: https://libav.org/ - -.. _Gitter: https://gitter.im/PyAV-Org -.. _GitHub: https://github.com/PyAV-Org/pyav diff --git a/docs/overview/caveats.rst b/docs/overview/caveats.rst index 5a8eb32d5..005401318 100644 --- a/docs/overview/caveats.rst +++ b/docs/overview/caveats.rst @@ -6,28 +6,21 @@ Caveats Authority of Documentation -------------------------- -FFmpeg is extremely complex, and the PyAV developers have not been successful in making it 100% clear to themselves in all aspects. Our understanding of how it works and how to work with it is via reading the docs, digging through the source, perfoming experiments, and hearing from users where PyAV isn't doing the right thing. +FFmpeg_ is extremely complex, and the PyAV developers have not been successful in making it 100% clear to themselves in all aspects. Our understanding of how it works and how to work with it is via reading the docs, digging through the source, performing experiments, and hearing from users where PyAV isn't doing the right thing. -Only where this documentation is about the mechanics of PyAV can it be considered authoritative. Anywhere that we discuss something that is actually about the underlying FFmpeg libraries comes with the caveat that we can not always be 100% on it. +Only where this documentation is about the mechanics of PyAV can it be considered authoritative. Anywhere that we discuss something that is about the underlying FFmpeg libraries comes with the caveat that we can not be 100% sure on it. It is, unfortunately, often on the user to understand and deal with edge cases. We encourage you to bring them to our attention via GitHub_ so that we can try to make PyAV deal with it. -It is, unfortunately, often on the user the understand and deal with the edge cases. We encourage you to bring them to our attension via GitHub_ so that we can try to make PyAV deal with it, but we can't always make it work. -.. _GitHub: https://github.com/PyAv-Org/PyAV/issues +Unsupported Features +-------------------- +Our goal is to provide all of the features that make sense for the contexts that PyAV would be used in. If there is something missing, please reach out on GitHub_ or open a feature request (or even better a pull request). Your request will be more likely to be addressed if you can point to the relevant `FFmpeg API documentation `__. -Sub-Interpeters ---------------- -Since we rely upon C callbacks in a few locations, PyAV is not fully compatible with sub-interpreters. Users have experienced lockups in WSGI web applications, for example. - -This is due to the ``PyGILState_Ensure`` calls made by Cython in a C callback from FFmpeg. If this is called in a thread that was not started by Python, it is very likely to break. There is no current instrumentation to detect such events. - -The two main features that are able to cause lockups are: - -1. Python IO (passing a file-like object to ``av.open``). While this is in theory possible, so far it seems like the callbacks are made in the calling thread, and so are safe. - -2. Logging. As soon as you en/decode with threads you are highly likely to get log messages issues from threads started by FFmpeg, and you will get lockups. See :ref:`disable_logging`. +Sub-Interpreters +---------------- +PyAV can enable Cython's per-interpreter module state as experimental groundwork, but it still cannot be imported by CPython sub-interpreters with their own GIL. Cython extension types continue to share internal vtable state between interpreters; repeated or concurrent interpreter creation can crash the process (`Cython issue #6445 `__). PyAV will not declare compatibility with own-GIL sub-interpreters until Cython supports extension types safely across interpreters. .. _garbage_collection: @@ -38,5 +31,9 @@ PyAV currently has a number of reference cycles that make it more difficult for Until we resolve this issue, you should explicitly call :meth:`.Container.close` or use the container as a context manager:: - with av.open(path) as fh: + with av.open(path) as container: # Do stuff with it. + + +.. _FFmpeg: https://ffmpeg.org/ +.. _GitHub: https://github.com/PyAV-Org/PyAV diff --git a/docs/overview/installation.rst b/docs/overview/installation.rst index 5a96b8bc1..30d5b0370 100644 --- a/docs/overview/installation.rst +++ b/docs/overview/installation.rst @@ -1,20 +1,36 @@ Installation ============ +Binary wheels +------------- + +PyAV requires Python 3.11 or later. Binary wheels are provided on PyPI for Linux, macOS, and Windows with FFmpeg bundled. The most straightforward way to install PyAV is to run: + +.. code-block:: bash + + pip install av + + Conda ----- -Due to the complexity of the dependencies, PyAV is not always the easiest Python package to install. The most straight-foward install is via `conda-forge `_:: +Another way to install PyAV is via `conda-forge `_:: conda install av -c conda-forge -See the `Conda quick install `_ docs to get started with (mini)Conda. +See the `Conda quick install `_ docs to get started with Miniconda. + + +Bring your own FFmpeg +--------------------- + +PyAV can also be compiled against your own build of FFmpeg 8.x. You can force installing PyAV from source by running: +.. code-block:: bash -Dependencies ------------- + pip install av --no-binary av -PyAV depends upon several libraries from FFmpeg (version ``4.0`` or higher): +PyAV depends upon several libraries from FFmpeg: - ``libavcodec`` - ``libavdevice`` @@ -30,114 +46,47 @@ and a few other tools in general: - Python's development headers -Mac OS X -^^^^^^^^ +macOS +^^^^^ -On **Mac OS X**, Homebrew_ saves the day:: +On **macOS**, install the build dependencies with Homebrew_:: brew install ffmpeg pkg-config -.. _homebrew: http://brew.sh/ - - -Ubuntu >= 18.04 LTS -^^^^^^^^^^^^^^^^^^^ - -On **Ubuntu 18.04 LTS** everything can come from the default sources:: - - # General dependencies - sudo apt-get install -y python-dev pkg-config - - # Library components - sudo apt-get install -y \ - libavformat-dev libavcodec-dev libavdevice-dev \ - libavutil-dev libswscale-dev libswresample-dev libavfilter-dev - - -Ubuntu < 18.04 LTS -^^^^^^^^^^^^^^^^^^ - -On older Ubuntu releases you will be unable to satisfy these requirements with the default package sources. We recommend compiling and installing FFmpeg from source. For FFmpeg:: - - sudo apt install \ - autoconf \ - automake \ - build-essential \ - cmake \ - libass-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libtheora-dev \ - libtool \ - libvorbis-dev \ - libx264-dev \ - pkg-config \ - wget \ - yasm \ - zlib1g-dev - - wget http://ffmpeg.org/releases/ffmpeg-3.2.tar.bz2 - tar -xjf ffmpeg-3.2.tar.bz2 - cd ffmpeg-3.2 - - ./configure --disable-static --enable-shared --disable-doc - make - sudo make install - -`See this script `_ for a very detailed installation of all dependencies. +.. _homebrew: https://brew.sh/ Windows ^^^^^^^ -It is possible to build PyAV on Windows without Conda by installing FFmpeg yourself, e.g. from the `shared and dev packages `_. - -Unpack them somewhere (like ``C:\ffmpeg``), and then :ref:`tell PyAV where they are located `. +The Windows build uses FFmpeg development files maintained by the PyAV project. From a PowerShell prompt, create a development environment, fetch the libraries into it, and pass their location to the build: +.. code-block:: powershell + conda create --name pyav-dev --channel conda-forge python=3.11 cython setuptools numpy pillow pytest + conda activate pyav-dev + $ffmpegDir = Join-Path $env:CONDA_PREFIX "Library" + python scripts\fetch-vendor.py --config-file scripts\ffmpeg-latest.json $ffmpegDir + python setup.py build_ext --inplace --ffmpeg-dir=$ffmpegDir + python -m pytest -PyAV ----- - - -Via PyPI/CheeseShop -^^^^^^^^^^^^^^^^^^^ -:: - - pip install av +This is the same approach used by PyAV's Windows continuous-integration build. -Via Source -^^^^^^^^^^ +Building from the latest source on Linux or macOS +------------------------------------------------- :: # Get PyAV from GitHub. - git clone git@github.com:PyAV-Org/PyAV.git + git clone https://github.com/PyAV-Org/PyAV.git cd PyAV # Prep a virtualenv. source scripts/activate.sh - # Install basic requirements. - pip install -r tests/requirements.txt - # Optionally build FFmpeg. ./scripts/build-deps # Build PyAV. make - # or - python setup.py build_ext --inplace - - -On **Mac OS X** you may have issues with regards to Python expecting gcc but finding clang. Try to export the following before installation:: - - export ARCHFLAGS=-Wno-error=unused-command-line-argument-hard-error-in-future - - -.. _build_on_windows: - -On **Windows** you must indicate the location of your FFmpeg, e.g.:: - - python setup.py build --ffmpeg-dir=C:\ffmpeg diff --git a/examples/audio/atempo.py b/examples/audio/atempo.py new file mode 100644 index 000000000..9123a7d9c --- /dev/null +++ b/examples/audio/atempo.py @@ -0,0 +1,32 @@ +import av + +av.logging.set_level(av.logging.VERBOSE) + +input_file = av.open("input.wav") +output_file = av.open("output.wav", mode="w") + +input_stream = input_file.streams.audio[0] +output_stream = output_file.add_stream("pcm_s16le", rate=input_stream.rate) + +graph = av.filter.Graph() +graph.link_nodes( + graph.add_abuffer(template=input_stream), + graph.add("atempo", "2.0"), + graph.add("abuffersink"), +).configure() + +for frame in input_file.decode(input_stream): + graph.push(frame) + while True: + try: + for packet in output_stream.encode(graph.pull()): + output_file.mux(packet) + except (av.BlockingIOError, av.EOFError): + break + +# Flush the stream +for packet in output_stream.encode(None): + output_file.mux(packet) + +input_file.close() +output_file.close() diff --git a/examples/basics/hw_decode.py b/examples/basics/hw_decode.py new file mode 100644 index 000000000..605ee1841 --- /dev/null +++ b/examples/basics/hw_decode.py @@ -0,0 +1,77 @@ +import os +import time + +import av +import av.datasets +from av.codec.hwaccel import HWAccel, hwdevices_available + +# What accelerator to use. +# Recommendations: +# Windows: +# - d3d11va (Direct3D 11) +# * available with built-in ffmpeg in PyAV binary wheels, and gives access to +# all decoders, but performance may not be as good as vendor native interfaces. +# - cuda (NVIDIA NVDEC), qsv (Intel QuickSync) +# * may be faster than d3d11va, but requires custom ffmpeg built with those libraries. +# Linux (all options require custom FFmpeg): +# - vaapi (Intel, AMD) +# - cuda (NVIDIA) +# Mac: +# - videotoolbox +# * available with built-in ffmpeg in PyAV binary wheels, and gives access to +# all accelerators available on Macs. This is the only option on MacOS. + +HW_DEVICE = os.environ["HW_DEVICE"] if "HW_DEVICE" in os.environ else None + +if "TEST_FILE_PATH" in os.environ: + test_file_path = os.environ["TEST_FILE_PATH"] +else: + test_file_path = av.datasets.curated( + "pexels/time-lapse-video-of-night-sky-857195.mp4" + ) + +if HW_DEVICE is None: + print(f"Please set HW_DEVICE. Options are: {hwdevices_available()}") + exit() + +assert HW_DEVICE in hwdevices_available(), f"{HW_DEVICE} not available." + +print("Decoding in software (auto threading)...") + +container = av.open(test_file_path) + +container.streams.video[0].thread_type = "AUTO" + +start_time = time.time() +frame_count = 0 +for packet in container.demux(video=0): + for _ in packet.decode(): + frame_count += 1 + +sw_time = time.time() - start_time +sw_fps = frame_count / sw_time +assert frame_count == container.streams.video[0].frames +container.close() + +print( + f"Decoded with software in {sw_time:.2f}s ({sw_fps:.2f} fps).\n" + f"Decoding with {HW_DEVICE}" +) + +hwaccel = HWAccel(device_type=HW_DEVICE, allow_software_fallback=False) + +# Note the additional argument here. +container = av.open(test_file_path, hwaccel=hwaccel) + +start_time = time.time() +frame_count = 0 +for packet in container.demux(video=0): + for _ in packet.decode(): + frame_count += 1 + +hw_time = time.time() - start_time +hw_fps = frame_count / hw_time +assert frame_count == container.streams.video[0].frames +container.close() + +print(f"Decoded with {HW_DEVICE} in {hw_time:.2f}s ({hw_fps:.2f} fps).") diff --git a/examples/basics/parse.py b/examples/basics/parse.py index 8e000b8d2..5d052449f 100644 --- a/examples/basics/parse.py +++ b/examples/basics/parse.py @@ -4,39 +4,41 @@ import av import av.datasets - # We want an H.264 stream in the Annex B byte-stream format. # We haven't exposed bitstream filters yet, so we're gonna use the `ffmpeg` CLI. -h264_path = 'night-sky.h264' +h264_path = "night-sky.h264" if not os.path.exists(h264_path): - subprocess.check_call([ - 'ffmpeg', - '-i', av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4'), - '-vcodec', 'copy', - '-an', - '-bsf:v', 'h264_mp4toannexb', - h264_path, - ]) + subprocess.check_call( + [ + "ffmpeg", + "-i", + av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4"), + "-vcodec", + "copy", + "-an", + "-bsf:v", + "h264_mp4toannexb", + h264_path, + ] + ) -fh = open(h264_path, 'rb') +fh = open(h264_path, "rb") -codec = av.CodecContext.create('h264', 'r') +codec = av.CodecContext.create("h264", "r") while True: - chunk = fh.read(1 << 16) packets = codec.parse(chunk) - print("Parsed {} packets from {} bytes:".format(len(packets), len(chunk))) + print(f"Parsed {len(packets)} packets from {len(chunk)} bytes:") for packet in packets: - - print(' ', packet) + print(" ", packet) frames = codec.decode(packet) for frame in frames: - print(' ', frame) + print(" ", frame) # We wait until the end to bail so that the last empty `buf` flushes # the parser. diff --git a/examples/basics/record_facecam.py b/examples/basics/record_facecam.py new file mode 100644 index 000000000..e554d25d2 --- /dev/null +++ b/examples/basics/record_facecam.py @@ -0,0 +1,47 @@ +import av + +av.logging.set_level(av.logging.VERBOSE) + + +""" +This is written for MacOS. Other platforms will need to init `input_` differently. +You may need to change the file "0". Use this API to list all devices: + + av.enumerate_input_devices("avfoundation") + +""" + +input_ = av.open( + "0", + format="avfoundation", + container_options={"framerate": "30", "video_size": "1920x1080"}, +) +output = av.open("out.mkv", "w") + +# Prefer x264, but use Apple hardware if not available. +try: + encoder = av.Codec("libx264", "w").name +except av.FFmpegError: + encoder = "h264_videotoolbox" + +output_stream = output.add_stream(encoder, rate=30) +output_stream.width = input_.streams.video[0].width +output_stream.height = input_.streams.video[0].height +output_stream.pix_fmt = "yuv420p" + +try: + while True: + try: + for frame in input_.decode(video=0): + packet = output_stream.encode(frame) + output.mux(packet) + except av.BlockingIOError: + pass +except KeyboardInterrupt: + print("Recording stopped by user") + +packet = output_stream.encode(None) +output.mux(packet) + +input_.close() +output.close() diff --git a/examples/basics/record_screen.py b/examples/basics/record_screen.py new file mode 100644 index 000000000..76b50a7e2 --- /dev/null +++ b/examples/basics/record_screen.py @@ -0,0 +1,42 @@ +import av + +av.logging.set_level(av.logging.VERBOSE) + +""" +This is written for MacOS. Other platforms will need a different file, format pair. +You may need to change the file "1". Use this API to list all devices: + + av.enumerate_input_devices("avfoundation") + +""" + +input_ = av.open("1", format="avfoundation") +output = av.open("out.mkv", "w") + +# Prefer x264, but use Apple hardware if not available. +try: + encoder = av.Codec("libx264", "w").name +except av.FFmpegError: + encoder = "h264_videotoolbox" + +output_stream = output.add_stream(encoder, rate=30) +output_stream.width = input_.streams.video[0].width +output_stream.height = input_.streams.video[0].height +output_stream.pix_fmt = "yuv420p" + +try: + while True: + try: + for frame in input_.decode(video=0): + packet = output_stream.encode(frame) + output.mux(packet) + except av.BlockingIOError: + pass +except KeyboardInterrupt: + print("Recording stopped by user") + +packet = output_stream.encode(None) +output.mux(packet) + +input_.close() +output.close() diff --git a/examples/basics/remux.py b/examples/basics/remux.py index d12266fb5..13247c41a 100644 --- a/examples/basics/remux.py +++ b/examples/basics/remux.py @@ -1,21 +1,24 @@ import av import av.datasets +av.logging.set_level(av.logging.VERBOSE) -input_ = av.open(av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4')) -output = av.open('remuxed.mkv', 'w') +input_ = av.open(av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4")) +output = av.open("remuxed.mkv", "w") # Make an output stream using the input as a template. This copies the stream # setup from one to the other. in_stream = input_.streams.video[0] -out_stream = output.add_stream(template=in_stream) +out_stream = output.add_stream_from_template(in_stream) for packet in input_.demux(in_stream): - print(packet) - # We need to skip the "flushing" packets that `demux` generates. - if packet.dts is None: + # We need to skip the empty "flushing" packet that `demux` generates at the + # end. Don't test `packet.dts is None` here: a valid keyframe can legitimately + # have no DTS (e.g. the leading reordered packets of a B-frame stream in MKV), + # and skipping it would drop the keyframe and corrupt the output. + if packet.size == 0: continue # We need to assign the packet to the new stream. diff --git a/examples/basics/save_keyframes.py b/examples/basics/save_keyframes.py index 2af00dc55..6a68de73b 100644 --- a/examples/basics/save_keyframes.py +++ b/examples/basics/save_keyframes.py @@ -1,19 +1,12 @@ import av import av.datasets - -content = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4') +content = av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") with av.open(content) as container: # Signal that we only want to look at keyframes. stream = container.streams.video[0] - stream.codec_context.skip_frame = 'NONKEY' - - for frame in container.decode(stream): + stream.codec_context.skip_frame = "NONKEY" + for i, frame in enumerate(container.decode(stream)): print(frame) - - # We use `frame.pts` as `frame.index` won't make must sense with the `skip_frame`. - frame.to_image().save( - 'night-sky.{:04d}.jpg'.format(frame.pts), - quality=80, - ) + frame.to_image().save(f"night-sky.{i:04d}.jpg", quality=80) diff --git a/examples/basics/thread_type.py b/examples/basics/thread_type.py index 665717e85..fab539b26 100644 --- a/examples/basics/thread_type.py +++ b/examples/basics/thread_type.py @@ -3,10 +3,11 @@ import av import av.datasets - print("Decoding with default (slice) threading...") -container = av.open(av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4')) +container = av.open( + av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") +) start_time = time.time() for packet in container.demux(): @@ -20,10 +21,12 @@ print("Decoding with auto threading...") -container = av.open(av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4')) +container = av.open( + av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") +) # !!! This is the only difference. -container.streams.video[0].thread_type = 'AUTO' +container.streams.video[0].thread_type = "AUTO" start_time = time.time() for packet in container.demux(): @@ -35,5 +38,5 @@ container.close() -print("Decoded with default threading in {:.2f}s.".format(default_time)) -print("Decoded with auto threading in {:.2f}s.".format(auto_time)) +print(f"Decoded with default threading in {default_time:.2f}s.") +print(f"Decoded with auto threading in {auto_time:.2f}s.") diff --git a/examples/numpy/barcode.py b/examples/numpy/barcode.py index 45300a87c..bd40f17a3 100644 --- a/examples/numpy/barcode.py +++ b/examples/numpy/barcode.py @@ -1,24 +1,24 @@ -from PIL import Image import numpy as np +from PIL import Image import av import av.datasets - -container = av.open(av.datasets.curated('pexels/time-lapse-video-of-sunset-by-the-sea-854400.mp4')) -container.streams.video[0].thread_type = 'AUTO' # Go faster! +container = av.open( + av.datasets.curated("pexels/time-lapse-video-of-sunset-by-the-sea-854400.mp4") +) +container.streams.video[0].thread_type = "AUTO" # Go faster! columns = [] for frame in container.decode(video=0): - print(frame) - array = frame.to_ndarray(format='rgb24') + array = frame.to_ndarray(format="rgb24") # Collapse down to a column. column = array.mean(axis=1) # Convert to bytes, as the `mean` turned our array into floats. - column = column.clip(0, 255).astype('uint8') + column = column.clip(0, 255).astype("uint8") # Get us in the right shape for the `hstack` below. column = column.reshape(-1, 1, 3) @@ -29,6 +29,6 @@ container.close() full_array = np.hstack(columns) -full_img = Image.fromarray(full_array, 'RGB') +full_img = Image.fromarray(full_array, "RGB") full_img = full_img.resize((800, 200)) -full_img.save('barcode.jpg', quality=85) +full_img.save("barcode.jpg", quality=85) diff --git a/examples/numpy/generate_video.py b/examples/numpy/generate_video.py index 9fc3a604a..27112d19f 100644 --- a/examples/numpy/generate_video.py +++ b/examples/numpy/generate_video.py @@ -1,33 +1,27 @@ - -from __future__ import division - import numpy as np import av - duration = 4 fps = 24 total_frames = duration * fps -container = av.open('test.mp4', mode='w') +container = av.open("test.mp4", mode="w") -stream = container.add_stream('mpeg4', rate=fps) +stream = container.add_stream("mpeg4", rate=fps) stream.width = 480 stream.height = 320 -stream.pix_fmt = 'yuv420p' +stream.pix_fmt = "yuv420p" for frame_i in range(total_frames): - - img = np.empty((480, 320, 3)) + img = np.empty((320, 480, 3)) img[:, :, 0] = 0.5 + 0.5 * np.sin(2 * np.pi * (0 / 3 + frame_i / total_frames)) img[:, :, 1] = 0.5 + 0.5 * np.sin(2 * np.pi * (1 / 3 + frame_i / total_frames)) img[:, :, 2] = 0.5 + 0.5 * np.sin(2 * np.pi * (2 / 3 + frame_i / total_frames)) img = np.round(255 * img).astype(np.uint8) - img = np.clip(img, 0, 255) - frame = av.VideoFrame.from_ndarray(img, format='rgb24') + frame = av.VideoFrame.from_ndarray(img, format="rgb24") for packet in stream.encode(frame): container.mux(packet) diff --git a/examples/numpy/generate_video_with_pts.py b/examples/numpy/generate_video_with_pts.py index 2ac835f66..8de95b63f 100644 --- a/examples/numpy/generate_video_with_pts.py +++ b/examples/numpy/generate_video_with_pts.py @@ -1,23 +1,31 @@ #!/usr/bin/env python3 -from fractions import Fraction import colorsys +from fractions import Fraction +from math import lcm import numpy as np import av - (width, height) = (640, 360) total_frames = 20 -fps = 30 - -container = av.open('generate_video_with_pts.mp4', mode='w') - -stream = container.add_stream('mpeg4', rate=fps) # alibi frame rate +fps = Fraction(30, 1) + +# MP4 stores a nonzero starting offset in an edit list using the movie timescale, +# which defaults to 1000. Choose a timescale that can represent frame-aligned +# offsets exactly; otherwise, a starting PTS such as 1 at 30 fps is rounded. +movie_timescale = lcm(1000, fps.numerator) +container = av.open( + "generate_video_with_pts.mp4", + mode="w", + container_options={"movie_timescale": str(movie_timescale)}, +) + +stream = container.add_stream("mpeg4", rate=fps) # alibi frame rate stream.width = width stream.height = height -stream.pix_fmt = 'yuv420p' +stream.pix_fmt = "yuv420p" # ffmpeg time is complicated # more at https://github.com/PyAV-Org/PyAV/blob/main/docs/api/time.rst @@ -43,7 +51,6 @@ block_h2 = int(0.5 * height / 4) for frame_i in range(total_frames): - # move around the color wheel (hue) nice_color = colorsys.hsv_to_rgb(frame_i / total_frames, 1.0, 1.0) nice_color = (np.array(nice_color) * 255).astype(np.uint8) @@ -51,9 +58,11 @@ # draw blocks of a progress bar cx = int(width / total_frames * (frame_i + 0.5)) cy = int(height / 2) - the_canvas[cy-block_h2: cy+block_h2, cx-block_w2: cx+block_w2] = nice_color + the_canvas[cy - block_h2 : cy + block_h2, cx - block_w2 : cx + block_w2] = ( + nice_color + ) - frame = av.VideoFrame.from_ndarray(the_canvas, format='rgb24') + frame = av.VideoFrame.from_ndarray(the_canvas, format="rgb24") # seconds -> counts of time_base frame.pts = int(round(my_pts / stream.codec_context.time_base)) @@ -70,7 +79,7 @@ # this black frame will probably be shown for 1/fps time # at least, that is the analysis of ffprobe the_canvas[:] = 0 -frame = av.VideoFrame.from_ndarray(the_canvas, format='rgb24') +frame = av.VideoFrame.from_ndarray(the_canvas, format="rgb24") frame.pts = int(round(my_pts / stream.codec_context.time_base)) for packet in stream.encode(frame): container.mux(packet) diff --git a/examples/subtitles/remux.py b/examples/subtitles/remux.py new file mode 100644 index 000000000..4fdc71f1d --- /dev/null +++ b/examples/subtitles/remux.py @@ -0,0 +1,25 @@ +import av + +av.logging.set_level(av.logging.VERBOSE) + +input_ = av.open("resources/webvtt.mkv") +output = av.open("remuxed.vtt", "w") + +in_stream = input_.streams.subtitles[0] +out_stream = output.add_stream_from_template(in_stream) + +for packet in input_.demux(in_stream): + if packet.size == 0: + continue + packet.stream = out_stream + output.mux(packet) + +input_.close() +output.close() + +print("Remuxing done") + +with av.open("remuxed.vtt") as f: + for subset in f.decode(subtitles=0): + for sub in subset: + print(sub.ass) diff --git a/flags.txt b/flags.txt deleted file mode 100644 index a2d730711..000000000 --- a/flags.txt +++ /dev/null @@ -1,53 +0,0 @@ - - -Objects with flags -=== -√ AVCodec.capabilities -√ AVCodecDescriptor.props -√ AVCodecContext.flags and flags2 -AVOutputFormat.flags - - - -Thoughts -=== - -- Having both individual properties AND the flags objects is kinda nice. -- I want lowercase flag/enum names, but to also work with the upper ones for b/c. - - -Option: av.enum flags. - - context.flags2 & 'EXPORT_MVS' - - context.flags2 |= 'EXPORT_MVS' - - new APIs: - - 'export_mvs' in context.flags2 - - context.flags2.export_mvs = True - - context.flags2['export_mvs'] = True - -Option: object which represents all flags, but can't work with integer values - - context.flags merges flags and flags2 - - this is really only handy on AVCodecContext, so... fuckit? - -Option: all exposed as individual properties - - context.export_mvs - - - This polutes the attribute space a lot. - - This feels the most "pythonic". - - If you can set multiple in constructors, then NBD if you want to do many. - - I don't like how I have to pick names. - - - - - -How to name -=== - -If a prefix is required, one of: - - is - - has - - can - - use - - do - - diff --git a/include/avcodec.pxd b/include/avcodec.pxd new file mode 100644 index 000000000..0a37cb91c --- /dev/null +++ b/include/avcodec.pxd @@ -0,0 +1,534 @@ +from libc.stdint cimport int64_t, uint8_t, uint16_t, uint32_t, uint64_t + +cdef extern from "libavutil/channel_layout.h" nogil: + ctypedef enum AVChannel: + AV_CHAN_NONE = -1 + AV_CHAN_FRONT_LEFT + AV_CHAN_FRONT_RIGHT + AV_CHAN_FRONT_CENTER + ctypedef struct AVChannelLayout: + int nb_channels + + void av_channel_layout_default(AVChannelLayout *ch_layout, int nb_channels) + int av_channel_layout_from_string(AVChannelLayout *channel_layout, const char *str) + int av_channel_layout_describe(const AVChannelLayout *channel_layout, char *buf, size_t buf_size) + int av_channel_name(char *buf, size_t buf_size, AVChannel channel_id) + int av_channel_description(char *buf, size_t buf_size, AVChannel channel_id) + int av_channel_layout_compare(const AVChannelLayout *chl, const AVChannelLayout *chl1) + AVChannel av_channel_layout_channel_from_index(const AVChannelLayout *channel_layout, unsigned int idx) + void av_channel_layout_uninit(AVChannelLayout *channel_layout) + +cdef extern from "libavcodec/avcodec.h" nogil: + cdef unsigned int avcodec_version() + cdef const char* avcodec_configuration() + cdef const char* avcodec_license() + + AVPixelFormat avcodec_find_best_pix_fmt_of_list( + const AVPixelFormat *pix_fmt_list, + AVPixelFormat src_pix_fmt, + int has_alpha, + int *loss_ptr, + ) + + cdef size_t AV_INPUT_BUFFER_PADDING_SIZE + cdef int64_t AV_NOPTS_VALUE + + cdef enum: + AV_CODEC_PROP_INTRA_ONLY + AV_CODEC_PROP_LOSSY + AV_CODEC_PROP_LOSSLESS + AV_CODEC_PROP_REORDER + AV_CODEC_PROP_BITMAP_SUB + AV_CODEC_PROP_TEXT_SUB + + cdef enum: + AV_CODEC_CAP_DRAW_HORIZ_BAND + AV_CODEC_CAP_DR1 + AV_CODEC_CAP_DELAY + AV_CODEC_CAP_SMALL_LAST_FRAME + AV_CODEC_CAP_EXPERIMENTAL + AV_CODEC_CAP_CHANNEL_CONF + AV_CODEC_CAP_FRAME_THREADS + AV_CODEC_CAP_SLICE_THREADS + AV_CODEC_CAP_PARAM_CHANGE + AV_CODEC_CAP_OTHER_THREADS + AV_CODEC_CAP_VARIABLE_FRAME_SIZE + AV_CODEC_CAP_AVOID_PROBING + AV_CODEC_CAP_HARDWARE + AV_CODEC_CAP_HYBRID + AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE + + cdef enum: + AV_PROFILE_UNKNOWN = -99 + + cdef enum: + FF_THREAD_FRAME + FF_THREAD_SLICE + + cdef enum: + AV_CODEC_FLAG_UNALIGNED + AV_CODEC_FLAG_QSCALE + AV_CODEC_FLAG_4MV + AV_CODEC_FLAG_OUTPUT_CORRUPT + AV_CODEC_FLAG_QPEL + AV_CODEC_FLAG_RECON_FRAME + AV_CODEC_FLAG_COPY_OPAQUE + AV_CODEC_FLAG_FRAME_DURATION + AV_CODEC_FLAG_PASS1 + AV_CODEC_FLAG_PASS2 + AV_CODEC_FLAG_LOOP_FILTER + AV_CODEC_FLAG_GRAY + AV_CODEC_FLAG_PSNR + AV_CODEC_FLAG_INTERLACED_DCT + AV_CODEC_FLAG_LOW_DELAY + AV_CODEC_FLAG_GLOBAL_HEADER + AV_CODEC_FLAG_BITEXACT + AV_CODEC_FLAG_AC_PRED + AV_CODEC_FLAG_INTERLACED_ME + AV_CODEC_FLAG_CLOSED_GOP + + cdef enum: + AV_CODEC_FLAG2_FAST + AV_CODEC_FLAG2_NO_OUTPUT + AV_CODEC_FLAG2_LOCAL_HEADER + AV_CODEC_FLAG2_CHUNKS + AV_CODEC_FLAG2_IGNORE_CROP + AV_CODEC_FLAG2_SHOW_ALL + AV_CODEC_FLAG2_EXPORT_MVS + AV_CODEC_FLAG2_SKIP_MANUAL + AV_CODEC_FLAG2_RO_FLUSH_NOOP + + cdef enum: + AV_PKT_FLAG_KEY + AV_PKT_FLAG_CORRUPT + AV_PKT_FLAG_DISCARD + AV_PKT_FLAG_TRUSTED + AV_PKT_FLAG_DISPOSABLE + + cdef enum: + AV_FRAME_FLAG_CORRUPT + AV_FRAME_FLAG_KEY + AV_FRAME_FLAG_DISCARD + AV_FRAME_FLAG_INTERLACED + + cdef enum: + FF_COMPLIANCE_VERY_STRICT + FF_COMPLIANCE_STRICT + FF_COMPLIANCE_NORMAL + FF_COMPLIANCE_UNOFFICIAL + FF_COMPLIANCE_EXPERIMENTAL + + cdef enum AVCodecID: + AV_CODEC_ID_NONE + AV_CODEC_ID_MPEG2VIDEO + AV_CODEC_ID_MPEG1VIDEO + AV_CODEC_ID_PCM_ALAW + AV_CODEC_ID_PCM_BLURAY + AV_CODEC_ID_PCM_DVD + AV_CODEC_ID_PCM_F16LE + AV_CODEC_ID_PCM_F24LE + AV_CODEC_ID_PCM_F32BE + AV_CODEC_ID_PCM_F32LE + AV_CODEC_ID_PCM_F64BE + AV_CODEC_ID_PCM_F64LE + AV_CODEC_ID_PCM_LXF + AV_CODEC_ID_PCM_MULAW + AV_CODEC_ID_PCM_S16BE + AV_CODEC_ID_PCM_S16BE_PLANAR + AV_CODEC_ID_PCM_S16LE + AV_CODEC_ID_PCM_S16LE_PLANAR + AV_CODEC_ID_PCM_S24BE + AV_CODEC_ID_PCM_S24DAUD + AV_CODEC_ID_PCM_S24LE + AV_CODEC_ID_PCM_S24LE_PLANAR + AV_CODEC_ID_PCM_S32BE + AV_CODEC_ID_PCM_S32LE + AV_CODEC_ID_PCM_S32LE_PLANAR + AV_CODEC_ID_PCM_S64BE + AV_CODEC_ID_PCM_S64LE + AV_CODEC_ID_PCM_S8 + AV_CODEC_ID_PCM_S8_PLANAR + AV_CODEC_ID_PCM_U16BE + AV_CODEC_ID_PCM_U16LE + AV_CODEC_ID_PCM_U24BE + AV_CODEC_ID_PCM_U24LE + AV_CODEC_ID_PCM_U32BE + AV_CODEC_ID_PCM_U32LE + AV_CODEC_ID_PCM_U8 + AV_CODEC_ID_PCM_VIDC + + cdef enum AVDiscard: + AVDISCARD_NONE + AVDISCARD_DEFAULT + AVDISCARD_NONREF + AVDISCARD_BIDIR + AVDISCARD_NONINTRA + AVDISCARD_NONKEY + AVDISCARD_ALL + + cdef struct AVCodec: + const char *name + const char *long_name + AVMediaType type + AVCodecID id + int capabilities + const AVClass *priv_class + + cdef int av_codec_is_encoder(const AVCodec*) + cdef int av_codec_is_decoder(const AVCodec*) + + cdef enum AVCodecConfig: + AV_CODEC_CONFIG_PIX_FORMAT + AV_CODEC_CONFIG_FRAME_RATE + AV_CODEC_CONFIG_SAMPLE_RATE + AV_CODEC_CONFIG_SAMPLE_FORMAT + + cdef int avcodec_get_supported_config( + const AVCodecContext *avctx, + const AVCodec *codec, + AVCodecConfig config, + unsigned flags, + const void **out_configs, + int *out_num_configs, + ) + + cdef struct AVProfile: + int profile + const char *name + + cdef struct AVCodecDescriptor: + AVCodecID id + AVMediaType type + const char *name + const char *long_name + int props + const char *const *mime_types + const AVProfile *profiles + + const AVCodecDescriptor* avcodec_descriptor_get(AVCodecID) + + cdef enum: + AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX + AV_CODEC_HW_CONFIG_METHOD_HW_FRAMES_CTX + AV_CODEC_HW_CONFIG_METHOD_INTERNAL + AV_CODEC_HW_CONFIG_METHOD_AD_HOC + + cdef struct AVCodecHWConfig: + AVPixelFormat pix_fmt + int methods + AVHWDeviceType device_type + cdef const AVCodecHWConfig* avcodec_get_hw_config(const AVCodec *codec, int index) + cdef struct AVHWAccel: + pass + + cdef enum AVFieldOrder: + pass + + cdef struct AVCodecContext: + const AVClass *av_class + + AVMediaType codec_type + const AVCodec *codec + AVCodecID codec_id + unsigned int codec_tag + + void* opaque + + int64_t bit_rate + int flags + int flags2 + uint8_t *extradata + int extradata_size + AVRational time_base + AVRational pkt_timebase + AVRational framerate + int delay + int width + int height + int coded_width + int coded_height + AVRational sample_aspect_ratio + AVPixelFormat pix_fmt + AVPixelFormat sw_pix_fmt + AVColorPrimaries color_primaries + AVColorTransferCharacteristic color_trc + AVColorSpace colorspace + AVColorRange color_range + AVFieldOrder field_order + + int has_b_frames + AVPixelFormat (*get_format)(AVCodecContext *s, const AVPixelFormat *fmt) + int max_b_frames + int mb_decision + int gop_size + int sample_rate + AVSampleFormat sample_fmt + AVChannelLayout ch_layout + int frame_size + + int bit_rate_tolerance + int global_quality + int compression_level + int qmin + int qmax + int rc_buffer_size + int64_t rc_max_rate + int64_t rc_min_rate + + const AVHWAccel *hwaccel + AVBufferRef *hw_device_ctx + AVBufferRef *hw_frames_ctx + + int thread_count + int thread_type + int bits_per_coded_sample + int profile + int level + AVDiscard skip_frame + + int subtitle_header_size + uint8_t *subtitle_header + int64_t frame_num + + cdef AVCodecContext* avcodec_alloc_context3(const AVCodec *codec) + cdef void avcodec_free_context(AVCodecContext **ctx) + cdef const AVClass* avcodec_get_class() + cdef const AVCodec* avcodec_find_decoder(AVCodecID id) + cdef const AVCodec* avcodec_find_encoder(AVCodecID id) + cdef const AVCodec* avcodec_find_decoder_by_name(const char *name) + cdef const AVCodec* avcodec_find_encoder_by_name(const char *name) + cdef const AVCodec* av_codec_iterate(void **opaque) + cdef const AVCodecDescriptor* avcodec_descriptor_get(AVCodecID id) + cdef const AVCodecDescriptor* avcodec_descriptor_get_by_name(const char *name) + cdef const char* avcodec_get_name(AVCodecID id) + cdef int avcodec_open2(AVCodecContext *ctx, const AVCodec *codec, AVDictionary **options) + cdef enum AVPacketSideDataType: + AV_PKT_DATA_NEW_EXTRADATA + AV_PKT_DATA_DISPLAYMATRIX + cdef struct AVPacketSideData: + uint8_t *data + size_t size + AVPacketSideDataType type + + cdef enum AVFrameSideDataType: + AV_FRAME_DATA_PANSCAN + AV_FRAME_DATA_A53_CC + AV_FRAME_DATA_STEREO3D + AV_FRAME_DATA_MATRIXENCODING + AV_FRAME_DATA_DOWNMIX_INFO + AV_FRAME_DATA_REPLAYGAIN + AV_FRAME_DATA_DISPLAYMATRIX + AV_FRAME_DATA_AFD + AV_FRAME_DATA_MOTION_VECTORS + AV_FRAME_DATA_SKIP_SAMPLES + AV_FRAME_DATA_AUDIO_SERVICE_TYPE + AV_FRAME_DATA_MASTERING_DISPLAY_METADATA + AV_FRAME_DATA_GOP_TIMECODE + AV_FRAME_DATA_SPHERICAL + AV_FRAME_DATA_CONTENT_LIGHT_LEVEL + AV_FRAME_DATA_ICC_PROFILE + AV_FRAME_DATA_S12M_TIMECODE + AV_FRAME_DATA_DYNAMIC_HDR_PLUS + AV_FRAME_DATA_REGIONS_OF_INTEREST + AV_FRAME_DATA_VIDEO_ENC_PARAMS + AV_FRAME_DATA_SEI_UNREGISTERED + AV_FRAME_DATA_FILM_GRAIN_PARAMS + AV_FRAME_DATA_DETECTION_BBOXES + AV_FRAME_DATA_DOVI_RPU_BUFFER + AV_FRAME_DATA_DOVI_METADATA + AV_FRAME_DATA_DYNAMIC_HDR_VIVID + AV_FRAME_DATA_AMBIENT_VIEWING_ENVIRONMENT + AV_FRAME_DATA_VIDEO_HINT + + cdef struct AVFrameSideData: + AVFrameSideDataType type + uint8_t *data + size_t size + AVDictionary *metadata + + # See: http://ffmpeg.org/doxygen/trunk/structAVFrame.html + cdef struct AVFrame: + uint8_t *data[8] + int linesize[8] + uint8_t **extended_data + int width + int height + int nb_samples + int format # -1 if unknown, AVPixelFormat or AVSampleFormat + AVPictureType pict_type + + int64_t pts + int64_t pkt_dts + void *opaque + int sample_rate + AVBufferRef *buf[8] + + AVFrameSideData **side_data + int nb_side_data + int flags + AVColorRange color_range + AVColorPrimaries color_primaries + AVColorTransferCharacteristic color_trc + AVColorSpace colorspace + + AVDictionary *metadata + int decode_error_flags + + AVBufferRef *hw_frames_ctx + AVBufferRef *opaque_ref + AVChannelLayout ch_layout + int64_t duration + + cdef struct AVPacket: + AVBufferRef *buf + int64_t pts + int64_t dts + uint8_t *data + int size + int stream_index + int flags + AVPacketSideData *side_data + int side_data_elems + int64_t duration + int64_t pos + void *opaque + AVBufferRef *opaque_ref + AVRational time_base + + cdef int avcodec_fill_audio_frame( + AVFrame *frame, + int nb_channels, + AVSampleFormat sample_fmt, + const uint8_t *buf, + int buf_size, + int align + ) + cdef AVPacket* av_packet_alloc() + cdef void av_packet_free(AVPacket **) + cdef int av_new_packet(AVPacket*, int) + cdef int av_packet_ref(AVPacket *dst, const AVPacket *src) + cdef void av_packet_unref(AVPacket *pkt) + cdef void av_packet_move_ref(AVPacket *dst, AVPacket *src) + cdef void av_packet_rescale_ts(AVPacket *pkt, AVRational src_tb, AVRational dst_tb) + + cdef enum AVSubtitleType: + SUBTITLE_NONE + SUBTITLE_BITMAP + SUBTITLE_TEXT + SUBTITLE_ASS + + cdef struct AVSubtitleRect: + int x + int y + int w + int h + int nb_colors + uint8_t *data[4] + int linesize[4] + AVSubtitleType type + char *text + char *ass + int flags + + cdef struct AVSubtitle: + uint16_t format + uint32_t start_display_time + uint32_t end_display_time + unsigned int num_rects + AVSubtitleRect **rects + int64_t pts + + cdef int avcodec_decode_subtitle2( + AVCodecContext *ctx, AVSubtitle *sub, int *done, const AVPacket *pkt, + ) + cdef int avcodec_encode_subtitle( + AVCodecContext *avctx, uint8_t *buf, int buf_size, const AVSubtitle *sub + ) + cdef void avsubtitle_free(AVSubtitle*) + cdef void avcodec_flush_buffers(AVCodecContext *ctx) + cdef int avcodec_send_packet(AVCodecContext *avctx, const AVPacket *packet) + cdef int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame) + cdef int avcodec_send_frame(AVCodecContext *avctx, const AVFrame *frame) + cdef int avcodec_receive_packet(AVCodecContext *avctx, AVPacket *avpkt) + + cdef struct AVCodecParser: + int codec_ids[7] + + cdef struct AVCodecParserContext: + int64_t pts + int64_t dts + int64_t pos + int64_t last_pos + int64_t offset + int duration + int key_frame + + cdef AVCodecParserContext *av_parser_init(int codec_id) + cdef int av_parser_parse2( + AVCodecParserContext *s, + AVCodecContext *avctx, + uint8_t **poutbuf, int *poutbuf_size, + const uint8_t *buf, int buf_size, + int64_t pts, int64_t dts, + int64_t pos + ) + cdef void av_parser_close(AVCodecParserContext *s) + + cdef struct AVCodecParameters: + AVMediaType codec_type + AVCodecID codec_id + uint8_t *extradata + int extradata_size + int width + int height + int sample_rate + AVPacketSideData *coded_side_data + int nb_coded_side_data + + cdef int avcodec_parameters_copy( + AVCodecParameters *dst, const AVCodecParameters *src + ) + cdef int avcodec_parameters_from_context( + AVCodecParameters *par, const AVCodecContext *codec + ) + cdef int avcodec_parameters_to_context( + AVCodecContext *codec, const AVCodecParameters *par + ) + + +cdef extern from "libavcodec/bsf.h" nogil: + cdef struct AVBitStreamFilter: + const char *name + const AVCodecID *codec_ids + + cdef struct AVCodecParameters: + pass + + cdef struct AVBSFContext: + const AVBitStreamFilter *filter + AVCodecParameters *par_in + AVCodecParameters *par_out + + cdef int av_bsf_list_parse_str(const char *str, AVBSFContext **bsf) + cdef int av_bsf_init(AVBSFContext *ctx) + cdef void av_bsf_free(AVBSFContext **ctx) + cdef const AVBitStreamFilter* av_bsf_iterate(void **opaque) + cdef int av_bsf_send_packet(AVBSFContext *ctx, AVPacket *pkt) + cdef int av_bsf_receive_packet(AVBSFContext *ctx, AVPacket *pkt) + cdef void av_bsf_flush(AVBSFContext *ctx) + +cdef extern from "libavcodec/packet.h" nogil: + const AVPacketSideData *av_packet_side_data_get( + const AVPacketSideData *sd, int nb_sd, AVPacketSideDataType type + ) + AVPacketSideData *av_packet_side_data_new( + AVPacketSideData **psd, int *pnb_sd, + AVPacketSideDataType type, size_t size, int flags + ) + uint8_t* av_packet_get_side_data( + const AVPacket *pkt, AVPacketSideDataType type, size_t *size + ) + int av_packet_add_side_data( + AVPacket *pkt, AVPacketSideDataType type, uint8_t *data, size_t size + ) + const char *av_packet_side_data_name(AVPacketSideDataType type) diff --git a/include/avdevice.pxd b/include/avdevice.pxd new file mode 100644 index 000000000..18a4dfed4 --- /dev/null +++ b/include/avdevice.pxd @@ -0,0 +1,30 @@ +cdef extern from "libavdevice/avdevice.h" nogil: + cdef unsigned int avdevice_version() + cdef const char* avdevice_configuration() + cdef const char* avdevice_license() + void avdevice_register_all() + + cdef struct AVDeviceInfo: + char *device_name + char *device_description + int nb_media_types + AVMediaType *media_types + + cdef struct AVDeviceInfoList: + AVDeviceInfo **devices + int nb_devices + int default_device + + int avdevice_list_input_sources( + const AVInputFormat *device, + const char *device_name, + AVDictionary *device_options, + AVDeviceInfoList **device_list, + ) + int avdevice_list_output_sinks( + const AVOutputFormat *device, + const char *device_name, + AVDictionary *device_options, + AVDeviceInfoList **device_list, + ) + void avdevice_free_list_devices(AVDeviceInfoList **device_list) diff --git a/include/avfilter.pxd b/include/avfilter.pxd new file mode 100644 index 000000000..2905a8c88 --- /dev/null +++ b/include/avfilter.pxd @@ -0,0 +1,101 @@ +cdef extern from "libavfilter/avfilter.h" nogil: + cdef unsigned int avfilter_version() + cdef const char* avfilter_configuration() + cdef const char* avfilter_license() + + cdef struct AVFilterPad: + pass + + const char* avfilter_pad_get_name(const AVFilterPad *pads, int index) + AVMediaType avfilter_pad_get_type(const AVFilterPad *pads, int index) + + cdef unsigned avfilter_filter_pad_count(const AVFilter *filter, int is_output) + + cdef struct AVFilter: + const char *name + const char *description + const AVFilterPad *inputs + const AVFilterPad *outputs + const AVClass *priv_class + int flags + + cdef const AVFilter* avfilter_get_by_name(const char *name) + cdef const AVFilter* av_filter_iterate(void **opaque) + + cdef struct AVFilterContext: + const AVClass *av_class + const AVFilter *filter + + char *name + + unsigned int nb_inputs + AVFilterPad *input_pads + AVFilterLink **inputs + + unsigned int nb_outputs + AVFilterPad *output_pads + AVFilterLink **outputs + + cdef int avfilter_init_str(AVFilterContext *ctx, const char *args) + cdef int avfilter_init_dict(AVFilterContext *ctx, AVDictionary **options) + cdef void avfilter_free(AVFilterContext*) + cdef const AVClass* avfilter_get_class() + + cdef struct AVFilterLink: + AVFilterContext *src + AVFilterPad *srcpad + AVFilterContext *dst + AVFilterPad *dstpad + AVMediaType type + int w + int h + AVRational sample_aspect_ratio + AVChannelLayout ch_layout + int sample_rate + int format + AVRational time_base + + cdef struct AVFilterGraph: + unsigned int nb_filters + AVFilterContext **filters + int nb_threads + + cdef struct AVFilterInOut: + char *name + AVFilterContext *filter_ctx + int pad_idx + AVFilterInOut *next + + cdef AVFilterGraph* avfilter_graph_alloc() + cdef void avfilter_graph_free(AVFilterGraph **ptr) + cdef AVFilterContext* avfilter_graph_alloc_filter( + AVFilterGraph *graph, + const AVFilter *filter, + const char *name + ) + cdef int avfilter_graph_create_filter( + AVFilterContext **filt_ctx, + const AVFilter *filt, + const char *name, + const char *args, + void *opaque, + AVFilterGraph *graph_ctx + ) + cdef int avfilter_link( + AVFilterContext *src, + unsigned int srcpad, + AVFilterContext *dst, + unsigned int dstpad + ) + cdef int avfilter_graph_config(AVFilterGraph *graph, void *logctx) + int avfilter_process_command( + AVFilterContext *filter, const char *cmd, const char *arg, char *res, + int res_len, int flags, + ) + +cdef extern from "libavfilter/buffersink.h" nogil: + cdef void av_buffersink_set_frame_size(AVFilterContext *ctx, unsigned frame_size) + int av_buffersink_get_frame(AVFilterContext *ctx, AVFrame *frame) + +cdef extern from "libavfilter/buffersrc.h" nogil: + int av_buffersrc_write_frame(AVFilterContext *ctx, const AVFrame *frame) diff --git a/include/libavformat/avformat.pxd b/include/avformat.pxd similarity index 54% rename from include/libavformat/avformat.pxd rename to include/avformat.pxd index 7a2285171..9a18e5a9b 100644 --- a/include/libavformat/avformat.pxd +++ b/include/avformat.pxd @@ -2,21 +2,15 @@ from libc.stdint cimport int64_t, uint64_t cdef extern from "libavformat/avformat.h" nogil: - - cdef int avformat_version() - cdef char* avformat_configuration() - cdef char* avformat_license() - cdef void avformat_network_init() - - cdef int64_t INT64_MIN + cdef unsigned int avformat_version() + cdef const char* avformat_configuration() + cdef const char* avformat_license() cdef int AV_TIME_BASE cdef int AVSEEK_FLAG_BACKWARD cdef int AVSEEK_FLAG_BYTE cdef int AVSEEK_FLAG_ANY cdef int AVSEEK_FLAG_FRAME - - cdef int AVIO_FLAG_WRITE cdef enum AVMediaType: @@ -26,29 +20,30 @@ cdef extern from "libavformat/avformat.h" nogil: AVMEDIA_TYPE_DATA AVMEDIA_TYPE_SUBTITLE AVMEDIA_TYPE_ATTACHMENT - AVMEDIA_TYPE_NB cdef struct AVStream: - int index int id - - AVCodecContext *codec + int disposition + AVDiscard discard AVCodecParameters *codecpar - AVRational time_base - int64_t start_time int64_t duration int64_t nb_frames int64_t cur_dts - AVDictionary *metadata - AVRational avg_frame_rate AVRational r_frame_rate AVRational sample_aspect_ratio + cdef struct AVChapter: + int64_t id + int64_t start + int64_t end + AVRational time_base + AVDictionary *metadata + # http://ffmpeg.org/doxygen/trunk/structAVIOContext.html cdef struct AVIOContext: unsigned char* buffer @@ -57,18 +52,14 @@ cdef extern from "libavformat/avformat.h" nogil: int direct int seekable int max_packet_size + void *opaque # http://ffmpeg.org/doxygen/trunk/structAVIOInterruptCB.html cdef struct AVIOInterruptCB: int (*callback)(void*) void *opaque - cdef int AVIO_FLAG_DIRECT cdef int AVIO_SEEKABLE_NORMAL - - cdef int SEEK_SET - cdef int SEEK_CUR - cdef int SEEK_END cdef int AVSEEK_SIZE cdef AVIOContext* avio_alloc_context( @@ -77,39 +68,27 @@ cdef extern from "libavformat/avformat.h" nogil: int write_flag, void *opaque, int(*read_packet)(void *opaque, uint8_t *buf, int buf_size), - int(*write_packet)(void *opaque, uint8_t *buf, int buf_size), + int(*write_packet)(void *opaque, const uint8_t *buf, int buf_size), int64_t(*seek)(void *opaque, int64_t offset, int whence) ) - # http://ffmpeg.org/doxygen/trunk/structAVInputFormat.html + # https://ffmpeg.org/doxygen/trunk/structAVInputFormat.html cdef struct AVInputFormat: const char *name const char *long_name const char *extensions int flags - # const AVCodecTag* const *codec_tag const AVClass *priv_class - cdef struct AVProbeData: - unsigned char *buf - int buf_size - const char *filename - - cdef AVInputFormat* av_probe_input_format( - AVProbeData *pd, - int is_opened - ) - - # http://ffmpeg.org/doxygen/trunk/structAVOutputFormat.html + # https://ffmpeg.org/doxygen/trunk/structAVOutputFormat.html cdef struct AVOutputFormat: const char *name const char *long_name const char *extensions - AVCodecID video_codec AVCodecID audio_codec + AVCodecID video_codec AVCodecID subtitle_codec int flags - # const AVCodecTag* const *codec_tag const AVClass *priv_class # AVInputFormat.flags and AVOutputFormat.flags @@ -127,7 +106,6 @@ cdef extern from "libavformat/avformat.h" nogil: AVFMT_NOBINSEARCH AVFMT_NOGENSEARCH AVFMT_NO_BYTE_SEEK - AVFMT_ALLOW_FLUSH AVFMT_TS_NONSTRICT AVFMT_TS_NEGATIVE AVFMT_SEEK_TO_PTS @@ -145,156 +123,88 @@ cdef extern from "libavformat/avformat.h" nogil: AVFMT_FLAG_DISCARD_CORRUPT AVFMT_FLAG_FLUSH_PACKETS AVFMT_FLAG_BITEXACT - AVFMT_FLAG_MP4A_LATM AVFMT_FLAG_SORT_DTS - AVFMT_FLAG_PRIV_OPT - AVFMT_FLAG_KEEP_SIDE_DATA # deprecated; does nothing AVFMT_FLAG_FAST_SEEK - AVFMT_FLAG_SHORTEST AVFMT_FLAG_AUTO_BSF - cdef int av_probe_input_buffer( - AVIOContext *pb, - AVInputFormat **fmt, - const char *filename, - void *logctx, - unsigned int offset, - unsigned int max_probe_size - ) + cdef int av_find_best_stream( + AVFormatContext *ic, + AVMediaType type, + int wanted_stream_nb, + int related_stream, + const AVCodec **decoder_ret, + int flags + ) - cdef AVInputFormat* av_find_input_format(const char *name) + cdef const AVInputFormat* av_find_input_format(const char *name) # http://ffmpeg.org/doxygen/trunk/structAVFormatContext.html cdef struct AVFormatContext: - - # Streams. + const AVClass *av_class unsigned int nb_streams AVStream **streams - - AVInputFormat *iformat - AVOutputFormat *oformat - + unsigned int nb_chapters + AVChapter **chapters + const AVInputFormat *iformat + const AVOutputFormat *oformat AVIOContext *pb AVIOInterruptCB interrupt_callback - AVDictionary *metadata - - char filename int64_t start_time int64_t duration - int bit_rate - + int64_t bit_rate int flags - int64_t max_analyze_duration - + AVCodecID audio_codec_id + AVCodecID video_codec_id + void *opaque + int (*io_open)( + AVFormatContext *s, + AVIOContext **pb, + const char *url, + int flags, + AVDictionary **options + ) + int (*io_close2)(AVFormatContext *s, AVIOContext *pb) cdef AVFormatContext* avformat_alloc_context() - - # .. c:function:: avformat_open_input(...) - # - # Options are passed via :func:`av.open`. - # - # .. seealso:: FFmpeg's docs: :ffmpeg:`avformat_open_input` - # cdef int avformat_open_input( - AVFormatContext **ctx, # NULL will allocate for you. - char *filename, - AVInputFormat *format, # Can be NULL. - AVDictionary **options # Can be NULL. - ) - - cdef int avformat_close_input(AVFormatContext **ctx) - - # .. c:function:: avformat_write_header(...) - # - # Options are passed via :func:`av.open`; called in - # :meth:`av.container.OutputContainer.start_encoding`. - # - # .. seealso:: FFmpeg's docs: :ffmpeg:`avformat_write_header` - # - cdef int avformat_write_header( - AVFormatContext *ctx, - AVDictionary **options # Can be NULL + AVFormatContext **ctx, + const char *filename, + const AVInputFormat *format, + AVDictionary **options ) + cdef void avformat_close_input(AVFormatContext **ctx) + cdef int avformat_write_header(AVFormatContext *ctx, AVDictionary **options) cdef int av_write_trailer(AVFormatContext *ctx) - - cdef int av_interleaved_write_frame( - AVFormatContext *ctx, - AVPacket *pkt + cdef int av_interleaved_write_frame(AVFormatContext *ctx, AVPacket *pkt) + cdef int av_write_frame(AVFormatContext *ctx, AVPacket *pkt) + cdef int avio_open(AVIOContext **s, const char *url, int flags) + cdef int64_t avio_size(AVIOContext *s) + cdef const AVOutputFormat* av_guess_format( + const char *short_name, const char *filename, const char *mime_type ) - - cdef int av_write_frame( - AVFormatContext *ctx, - AVPacket *pkt - ) - - cdef int avio_open( - AVIOContext **s, - char *url, - int flags - ) - - cdef int64_t avio_size( - AVIOContext *s - ) - - cdef AVOutputFormat* av_guess_format( - char *short_name, - char *filename, - char *mime_type - ) - cdef int avformat_query_codec( - AVOutputFormat *ofmt, - AVCodecID codec_id, - int std_compliance + const AVOutputFormat *ofmt, AVCodecID codec_id, int std_compliance ) - + cdef void avio_flush(AVIOContext *s) cdef int avio_close(AVIOContext *s) - cdef int avio_closep(AVIOContext **s) - - cdef int avformat_find_stream_info( - AVFormatContext *ctx, - AVDictionary **options, # Can be NULL. - ) - - cdef AVStream* avformat_new_stream( - AVFormatContext *ctx, - AVCodec *c - ) - + cdef int avformat_find_stream_info(AVFormatContext *ctx, AVDictionary **options) + cdef AVStream* avformat_new_stream(AVFormatContext *ctx, const AVCodec *c) cdef int avformat_alloc_output_context2( AVFormatContext **ctx, - AVOutputFormat *oformat, - char *format_name, - char *filename - ) - - cdef int avformat_free_context(AVFormatContext *ctx) - - cdef AVClass* avformat_get_class() - - cdef void av_dump_format( - AVFormatContext *ctx, - int index, - char *url, - int is_output, - ) - - cdef int av_read_frame( - AVFormatContext *ctx, - AVPacket *packet, + const AVOutputFormat *oformat, + const char *format_name, + const char *filename ) - + cdef void avformat_free_context(AVFormatContext *ctx) + cdef const AVClass* avformat_get_class() + cdef void av_dump_format(AVFormatContext *ctx, int index, const char *url, int is_output) + cdef int av_read_frame(AVFormatContext *ctx, AVPacket *packet) cdef int av_seek_frame( - AVFormatContext *ctx, - int stream_index, - int64_t timestamp, - int flags + AVFormatContext *ctx, int stream_index, int64_t timestamp, int flags ) - cdef int avformat_seek_file( AVFormatContext *ctx, int stream_index, @@ -303,16 +213,28 @@ cdef extern from "libavformat/avformat.h" nogil: int64_t max_ts, int flags ) - cdef AVRational av_guess_frame_rate( - AVFormatContext *ctx, - AVStream *stream, - AVFrame *frame + AVFormatContext *ctx, AVStream *stream, AVFrame *frame + ) + cdef AVRational av_guess_sample_aspect_ratio( + AVFormatContext *ctx, AVStream *stream, AVFrame *frame ) - cdef const AVInputFormat* av_demuxer_iterate(void **opaque) cdef const AVOutputFormat* av_muxer_iterate(void **opaque) - # custom - cdef set pyav_get_available_formats() + + cdef struct AVIndexEntry: + int64_t pos + int64_t timestamp + int flags + int size + int min_distance + + cdef enum: + AVINDEX_KEYFRAME + AVINDEX_DISCARD_FRAME + + cdef const AVIndexEntry *avformat_index_get_entry(AVStream *st, int idx) + cdef int avformat_index_get_entries_count(const AVStream *st) + cdef int av_index_search_timestamp(AVStream *st, int64_t timestamp, int flags) diff --git a/include/avutil.pxd b/include/avutil.pxd new file mode 100644 index 000000000..8818fdd14 --- /dev/null +++ b/include/avutil.pxd @@ -0,0 +1,438 @@ +from libc.stdint cimport int16_t, int32_t, int64_t, uint8_t, uint16_t, uint32_t, uint64_t + + +cdef extern from "libavutil/audio_fifo.h" nogil: + cdef struct AVAudioFifo: + pass + cdef void av_audio_fifo_free(AVAudioFifo *af) + cdef AVAudioFifo* av_audio_fifo_alloc( + AVSampleFormat sample_fmt, int channels, int nb_samples + ) + cdef int av_audio_fifo_write(AVAudioFifo *af, void *const *data, int nb_samples) + cdef int av_audio_fifo_read(AVAudioFifo *af, void *const *data, int nb_samples) + cdef int av_audio_fifo_size(AVAudioFifo *af) + +cdef extern from "libavutil/avutil.h" nogil: + cdef const char* av_version_info() + cdef unsigned int avutil_version() + cdef const char* avutil_configuration() + cdef const char* avutil_license() + + int FF_QP2LAMBDA + + cdef enum AVPictureType: + AV_PICTURE_TYPE_NONE + AV_PICTURE_TYPE_I + AV_PICTURE_TYPE_P + AV_PICTURE_TYPE_B + AV_PICTURE_TYPE_S + AV_PICTURE_TYPE_SI + AV_PICTURE_TYPE_SP + AV_PICTURE_TYPE_BI + + cdef enum AVPixelFormat: + AV_PIX_FMT_NONE + AV_PIX_FMT_YUV420P + + cdef enum AVColorSpace: + AVCOL_SPC_RGB + AVCOL_SPC_BT709 + AVCOL_SPC_UNSPECIFIED + AVCOL_SPC_RESERVED + AVCOL_SPC_FCC + AVCOL_SPC_BT470BG + AVCOL_SPC_SMPTE170M + AVCOL_SPC_SMPTE240M + AVCOL_SPC_YCOCG + AVCOL_SPC_BT2020_NCL + AVCOL_SPC_BT2020_CL + AVCOL_SPC_NB + + cdef enum AVColorRange: + AVCOL_RANGE_UNSPECIFIED + AVCOL_RANGE_MPEG + AVCOL_RANGE_JPEG + AVCOL_RANGE_NB + + cdef enum AVColorPrimaries: + AVCOL_PRI_BT709 + AVCOL_PRI_UNSPECIFIED + AVCOL_PRI_BT470M + AVCOL_PRI_BT470BG + AVCOL_PRI_SMPTE170M + AVCOL_PRI_SMPTE240M + AVCOL_PRI_FILM + AVCOL_PRI_BT2020 + AVCOL_PRI_SMPTE428 + AVCOL_PRI_SMPTEST428_1 + AVCOL_PRI_SMPTE431 + AVCOL_PRI_SMPTE432 + AVCOL_PRI_EBU3213 + AVCOL_PRI_JEDEC_P22 + + cdef enum AVColorTransferCharacteristic: + AVCOL_TRC_BT709 + AVCOL_TRC_UNSPECIFIED + AVCOL_TRC_GAMMA22 + AVCOL_TRC_GAMMA28 + AVCOL_TRC_SMPTE170M + AVCOL_TRC_SMPTE240M + AVCOL_TRC_LINEAR + AVCOL_TRC_LOG + AVCOL_TRC_LOG_SQRT + AVCOL_TRC_IEC61966_2_4 + AVCOL_TRC_BT1361_ECG + AVCOL_TRC_IEC61966_2_1 + AVCOL_TRC_BT2020_10 + AVCOL_TRC_BT2020_12 + AVCOL_TRC_SMPTE2084 + AVCOL_TRC_SMPTEST2084 + AVCOL_TRC_SMPTE428 + AVCOL_TRC_SMPTEST428_1 + AVCOL_TRC_ARIB_STD_B67 + + cdef void* av_malloc(size_t size) + cdef void* av_mallocz(size_t size) + cdef void* av_realloc(void *ptr, size_t size) + cdef void av_free(void *ptr) + cdef void av_freep(void *ptr) + cdef int av_get_bytes_per_sample(AVSampleFormat sample_fmt) + cdef int av_samples_get_buffer_size( + int *linesize, + int nb_channels, + int nb_samples, + AVSampleFormat sample_fmt, + int align + ) + ctypedef struct AVRational: + int num + int den + cdef int64_t av_rescale_q(int64_t a, AVRational bq, AVRational cq) + cdef int64_t av_rescale(int64_t a, int64_t b, int64_t c) + cdef const char* av_get_media_type_string(AVMediaType media_type) + +cdef extern from "libavutil/buffer.h" nogil: + AVBufferRef *av_buffer_create(uint8_t *data, size_t size, void (*free)(void *opaque, uint8_t *data), void *opaque, int flags) + AVBufferRef* av_buffer_ref(const AVBufferRef *buf) + void av_buffer_unref(AVBufferRef **buf) + cdef struct AVBuffer: + pass + cdef struct AVBufferRef: + AVBuffer *buffer + uint8_t *data + size_t size + +cdef extern from "libavutil/dict.h" nogil: + # See: http://ffmpeg.org/doxygen/trunk/structAVDictionary.html + ctypedef struct AVDictionary: + pass + # See: http://ffmpeg.org/doxygen/trunk/structAVDictionaryEntry.html + ctypedef struct AVDictionaryEntry: + char *key + char *value + cdef int AV_DICT_IGNORE_SUFFIX + cdef void av_dict_free(AVDictionary **) + cdef AVDictionaryEntry* av_dict_get( + const AVDictionary *dict, + const char *key, + const AVDictionaryEntry *prev, + int flags + ) + cdef int av_dict_set( + AVDictionary **pm, const char *key, const char *value, int flags + ) + cdef int av_dict_count(const AVDictionary *m) + cdef int av_dict_copy(AVDictionary **dst, const AVDictionary *src, int flags) + +cdef extern from "libavutil/display.h" nogil: + cdef double av_display_rotation_get(const int32_t matrix[9]) + cdef void av_display_rotation_set(int32_t matrix[9], double angle) + cdef void av_display_matrix_flip(int32_t matrix[9], int hflip, int vflip) + +cdef extern from "libavutil/error.h" nogil: + cdef int AVERROR_BSF_NOT_FOUND + cdef int AVERROR_BUG + cdef int AVERROR_BUFFER_TOO_SMALL + cdef int AVERROR_DECODER_NOT_FOUND + cdef int AVERROR_DEMUXER_NOT_FOUND + cdef int AVERROR_ENCODER_NOT_FOUND + cdef int AVERROR_EOF + cdef int AVERROR_EXIT + cdef int AVERROR_EXTERNAL + cdef int AVERROR_FILTER_NOT_FOUND + cdef int AVERROR_INVALIDDATA + cdef int AVERROR_MUXER_NOT_FOUND + cdef int AVERROR_OPTION_NOT_FOUND + cdef int AVERROR_PATCHWELCOME + cdef int AVERROR_PROTOCOL_NOT_FOUND + cdef int AVERROR_UNKNOWN + cdef int AVERROR_EXPERIMENTAL + cdef int AVERROR_INPUT_CHANGED + cdef int AVERROR_OUTPUT_CHANGED + cdef int AVERROR_HTTP_BAD_REQUEST + cdef int AVERROR_HTTP_UNAUTHORIZED + cdef int AVERROR_HTTP_FORBIDDEN + cdef int AVERROR_HTTP_NOT_FOUND + cdef int AVERROR_HTTP_TOO_MANY_REQUESTS + cdef int AVERROR_HTTP_OTHER_4XX + cdef int AVERROR_HTTP_SERVER_ERROR + cdef int AV_ERROR_MAX_STRING_SIZE + cdef int av_strerror(int errno, char *output, size_t output_size) + cdef char* av_err2str(int errnum) + +cdef extern from "libavutil/frame.h" nogil: + cdef AVFrame* av_frame_alloc() + cdef void av_frame_free(AVFrame**) + cdef int av_frame_ref(AVFrame *dst, const AVFrame *src) + cdef void av_frame_unref(AVFrame *frame) + cdef int av_frame_get_buffer(AVFrame *frame, int align) + cdef int av_frame_make_writable(AVFrame *frame) + cdef int av_frame_copy_props(AVFrame *dst, const AVFrame *src) + cdef AVFrameSideData* av_frame_get_side_data(const AVFrame *frame, AVFrameSideDataType type) + +cdef extern from "libavutil/hwcontext.h" nogil: + cdef struct AVHWDeviceContext: + void *hwctx + void (*free)(AVHWDeviceContext *ctx) + void *user_opaque + + enum AVHWDeviceType: + AV_HWDEVICE_TYPE_NONE + AV_HWDEVICE_TYPE_VDPAU + AV_HWDEVICE_TYPE_CUDA + AV_HWDEVICE_TYPE_VAAPI + AV_HWDEVICE_TYPE_DXVA2 + AV_HWDEVICE_TYPE_QSV + AV_HWDEVICE_TYPE_VIDEOTOOLBOX + AV_HWDEVICE_TYPE_D3D11VA + AV_HWDEVICE_TYPE_DRM + AV_HWDEVICE_TYPE_OPENCL + AV_HWDEVICE_TYPE_MEDIACODEC + AV_HWDEVICE_TYPE_VULKAN + AV_HWDEVICE_TYPE_D3D12VA + + ctypedef struct AVHWFramesContext: + const AVClass *av_class + AVBufferRef *device_ref + AVHWDeviceContext *device_ctx + void *hwctx + int initial_pool_size + AVPixelFormat format + AVPixelFormat sw_format + int width + int height + + cdef AVBufferRef *av_hwdevice_ctx_alloc(AVHWDeviceType type) + cdef int av_hwdevice_ctx_init(AVBufferRef *ref) + cdef int av_hwdevice_ctx_create(AVBufferRef **device_ctx, AVHWDeviceType type, const char *device, AVDictionary *opts, int flags) + cdef AVHWDeviceType av_hwdevice_find_type_by_name(const char *name) + cdef const char *av_hwdevice_get_type_name(AVHWDeviceType type) + cdef AVHWDeviceType av_hwdevice_iterate_types(AVHWDeviceType prev) + cdef int av_hwframe_transfer_data(AVFrame *dst, const AVFrame *src, int flags) + cdef int av_hwframe_get_buffer(AVBufferRef *hwframe_ctx, AVFrame *frame, int flags) + + cdef AVBufferRef *av_hwframe_ctx_alloc(AVBufferRef *device_ref) + cdef int av_hwframe_ctx_init(AVBufferRef *ref) + +cdef extern from "libavutil/imgutils.h" nogil: + cdef int av_image_alloc( + uint8_t *pointers[4], + int linesizes[4], + int width, + int height, + AVPixelFormat pix_fmt, + int align + ) + cdef int av_image_fill_pointers( + uint8_t *pointers[4], + AVPixelFormat pix_fmt, + int height, + uint8_t *ptr, + const int linesizes[4] + ) + +cdef extern from "libavutil/log.h" nogil: + cdef struct AVClass: + const char *class_name + const char *(*item_name)(void*) nogil + const AVOption *option + + cdef enum: + AV_LOG_QUIET + AV_LOG_PANIC + AV_LOG_FATAL + AV_LOG_ERROR + AV_LOG_WARNING + AV_LOG_INFO + AV_LOG_VERBOSE + AV_LOG_DEBUG + AV_LOG_TRACE + + void av_log(void *ptr, int level, const char *fmt, ...) + ctypedef void(*av_log_callback)(void *, int, const char *, va_list) + void av_log_default_callback(void *, int, const char *, va_list) + void av_log_set_callback (av_log_callback callback) + void av_log_set_level(int level) + +cdef extern from "libavutil/motion_vector.h" nogil: + cdef struct AVMotionVector: + int32_t source + uint8_t w + uint8_t h + int16_t src_x + int16_t src_y + int16_t dst_x + int16_t dst_y + uint64_t flags + int32_t motion_x + int32_t motion_y + uint16_t motion_scale + +cdef extern from "libavutil/opt.h" nogil: + cdef enum AVOptionType: + AV_OPT_TYPE_FLAGS + AV_OPT_TYPE_INT + AV_OPT_TYPE_INT64 + AV_OPT_TYPE_DOUBLE + AV_OPT_TYPE_FLOAT + AV_OPT_TYPE_STRING + AV_OPT_TYPE_RATIONAL + AV_OPT_TYPE_BINARY + AV_OPT_TYPE_DICT + AV_OPT_TYPE_UINT64 + AV_OPT_TYPE_CONST + AV_OPT_TYPE_IMAGE_SIZE + AV_OPT_TYPE_PIXEL_FMT + AV_OPT_TYPE_SAMPLE_FMT + AV_OPT_TYPE_VIDEO_RATE + AV_OPT_TYPE_DURATION + AV_OPT_TYPE_COLOR + AV_OPT_TYPE_CHLAYOUT + AV_OPT_TYPE_BOOL + AV_OPT_TYPE_UINT + AV_OPT_TYPE_FLAG_ARRAY + + cdef union AVOption_default_val: + int64_t i64 + double dbl + const char *str + AVRational q + + cdef enum: + AV_OPT_FLAG_ENCODING_PARAM + AV_OPT_FLAG_DECODING_PARAM + AV_OPT_FLAG_AUDIO_PARAM + AV_OPT_FLAG_VIDEO_PARAM + AV_OPT_FLAG_SUBTITLE_PARAM + AV_OPT_FLAG_EXPORT + AV_OPT_FLAG_READONLY + AV_OPT_FLAG_BSF_PARAM + AV_OPT_FLAG_RUNTIME_PARAM + AV_OPT_FLAG_FILTERING_PARAM + AV_OPT_FLAG_DEPRECATED + AV_OPT_FLAG_CHILD_CONSTS + + cdef struct AVOption: + const char *name + const char *help + AVOptionType type + int offset + AVOption_default_val default_val + double min + double max + int flags + const char *unit + + cdef const AVOption *av_opt_next(const void *obj, const AVOption *prev) + cdef void *av_opt_child_next(void *obj, void *prev) + cdef int av_opt_get( + void *obj, const char *name, int search_flags, uint8_t **out_val + ) + +cdef extern from "libavutil/pixdesc.h" nogil: + # See: http://ffmpeg.org/doxygen/trunk/structAVComponentDescriptor.html + cdef struct AVComponentDescriptor: + int plane + int step + int offset + int shift + int depth + + cdef enum AVPixFmtFlags: + AV_PIX_FMT_FLAG_BE + AV_PIX_FMT_FLAG_PAL + AV_PIX_FMT_FLAG_BITSTREAM + AV_PIX_FMT_FLAG_PLANAR + AV_PIX_FMT_FLAG_RGB + AV_PIX_FMT_FLAG_BAYER + + # See: http://ffmpeg.org/doxygen/trunk/structAVPixFmtDescriptor.html + cdef struct AVPixFmtDescriptor: + const char *name + uint8_t nb_components + uint8_t log2_chroma_w + uint8_t log2_chroma_h + uint64_t flags + AVComponentDescriptor comp[4] + + cdef const AVPixFmtDescriptor* av_pix_fmt_desc_get(AVPixelFormat pix_fmt) + cdef const AVPixFmtDescriptor* av_pix_fmt_desc_next(const AVPixFmtDescriptor *prev) + cdef const char *av_get_pix_fmt_name(AVPixelFormat pix_fmt) + cdef AVPixelFormat av_get_pix_fmt(const char *name) + int av_get_bits_per_pixel(const AVPixFmtDescriptor *pixdesc) + int av_get_padded_bits_per_pixel(const AVPixFmtDescriptor *pixdesc) + +cdef extern from "libavutil/rational.h" nogil: + cdef int av_reduce(int *dst_num, int *dst_den, int64_t num, int64_t den, int64_t max) + cdef AVRational av_mul_q(AVRational b, AVRational c) + cdef AVRational av_div_q(AVRational b, AVRational c) + cdef AVRational av_add_q(AVRational b, AVRational c) + cdef AVRational av_sub_q(AVRational b, AVRational c) + +cdef extern from "libavutil/samplefmt.h" nogil: + cdef enum AVSampleFormat: + AV_SAMPLE_FMT_U8 + AV_SAMPLE_FMT_S16 + AV_SAMPLE_FMT_S32 + AV_SAMPLE_FMT_FLT + AV_SAMPLE_FMT_DBL + + cdef AVSampleFormat av_get_sample_fmt(const char *name) + cdef const char *av_get_sample_fmt_name(AVSampleFormat sample_fmt) + cdef int av_get_bytes_per_sample(AVSampleFormat sample_fmt) + cdef int av_sample_fmt_is_planar(AVSampleFormat sample_fmt) + cdef AVSampleFormat av_get_packed_sample_fmt(AVSampleFormat sample_fmt) + cdef AVSampleFormat av_get_planar_sample_fmt(AVSampleFormat sample_fmt) + cdef int av_samples_get_buffer_size( + int *linesize, + int nb_channels, + int nb_samples, + AVSampleFormat sample_fmt, + int align + ) + +cdef extern from "libavutil/video_enc_params.h" nogil: + cdef enum AVVideoEncParamsType: + AV_VIDEO_ENC_PARAMS_NONE + AV_VIDEO_ENC_PARAMS_VP9 + AV_VIDEO_ENC_PARAMS_H264 + AV_VIDEO_ENC_PARAMS_MPEG2 + + cdef struct AVVideoEncParams: + unsigned int nb_blocks + size_t blocks_offset + size_t block_size + AVVideoEncParamsType type + int32_t qp + int32_t delta_qp[4][2] + + cdef struct AVVideoBlockParams: + int src_x + int src_y + int w + int h + int32_t delta_qp + +cdef extern from "stdarg.h" nogil: + ctypedef struct va_list: + pass diff --git a/include/hwcontext_cuda.pxd b/include/hwcontext_cuda.pxd new file mode 100644 index 000000000..b97962967 --- /dev/null +++ b/include/hwcontext_cuda.pxd @@ -0,0 +1,19 @@ +cdef extern from *: + """ + #ifndef CUDA_VERSION + #define PYAV_CUDA_TYPES + #define CUDA_VERSION 1 + typedef struct CUctx_st *CUcontext; + typedef struct CUstream_st *CUstream; + #endif + #include + #ifdef PYAV_CUDA_TYPES + #undef CUDA_VERSION + #undef PYAV_CUDA_TYPES + #endif + """ + ctypedef void *CUcontext + ctypedef void *CUstream + ctypedef struct AVCUDADeviceContext: + CUcontext cuda_ctx + CUstream stream diff --git a/include/libav.pxd b/include/libav.pxd index 60099df04..333815d80 100644 --- a/include/libav.pxd +++ b/include/libav.pxd @@ -1,32 +1,5 @@ - -# This file is built by setup.py and contains macros telling us which libraries -# and functions we have (of those which are different between FFMpeg and LibAV). -cdef extern from "pyav/config.h" nogil: - - char* PYAV_VERSION_STR - char* PYAV_COMMIT_STR - -include "libavutil/avutil.pxd" -include "libavutil/channel_layout.pxd" -include "libavutil/dict.pxd" -include "libavutil/error.pxd" -include "libavutil/frame.pxd" -include "libavutil/samplefmt.pxd" -include "libavutil/motion_vector.pxd" - -include "libavcodec/avcodec.pxd" -include "libavdevice/avdevice.pxd" -include "libavformat/avformat.pxd" -include "libswresample/swresample.pxd" -include "libswscale/swscale.pxd" - -include "libavfilter/avfilter.pxd" -include "libavfilter/avfiltergraph.pxd" -include "libavfilter/buffersink.pxd" -include "libavfilter/buffersrc.pxd" - - -cdef extern from "stdio.h" nogil: - - cdef int snprintf(char *output, int n, const char *format, ...) - cdef int vsnprintf(char *output, int n, const char *format, va_list args) +include "avutil.pxd" +include "avcodec.pxd" +include "avformat.pxd" +include "avfilter.pxd" +include "avdevice.pxd" diff --git a/include/libavcodec/avcodec.pxd b/include/libavcodec/avcodec.pxd deleted file mode 100644 index 7e921e847..000000000 --- a/include/libavcodec/avcodec.pxd +++ /dev/null @@ -1,454 +0,0 @@ -from libc.stdint cimport ( - uint8_t, int8_t, - uint16_t, int16_t, - uint32_t, int32_t, - uint64_t, int64_t -) - - -cdef extern from "libavcodec/avcodec.h" nogil: - - # custom - cdef set pyav_get_available_codecs() - - cdef int avcodec_version() - cdef char* avcodec_configuration() - cdef char* avcodec_license() - - cdef size_t AV_INPUT_BUFFER_PADDING_SIZE - cdef int64_t AV_NOPTS_VALUE - - # AVCodecDescriptor.props - cdef enum: - AV_CODEC_PROP_INTRA_ONLY - AV_CODEC_PROP_LOSSY - AV_CODEC_PROP_LOSSLESS - AV_CODEC_PROP_REORDER - AV_CODEC_PROP_BITMAP_SUB - AV_CODEC_PROP_TEXT_SUB - - #AVCodec.capabilities - cdef enum: - AV_CODEC_CAP_DRAW_HORIZ_BAND - AV_CODEC_CAP_DR1 - AV_CODEC_CAP_TRUNCATED - # AV_CODEC_CAP_HWACCEL - AV_CODEC_CAP_DELAY - AV_CODEC_CAP_SMALL_LAST_FRAME - # AV_CODEC_CAP_HWACCEL_VDPAU - AV_CODEC_CAP_SUBFRAMES - AV_CODEC_CAP_EXPERIMENTAL - AV_CODEC_CAP_CHANNEL_CONF - # AV_CODEC_CAP_NEG_LINESIZES - AV_CODEC_CAP_FRAME_THREADS - AV_CODEC_CAP_SLICE_THREADS - AV_CODEC_CAP_PARAM_CHANGE - AV_CODEC_CAP_AUTO_THREADS - AV_CODEC_CAP_VARIABLE_FRAME_SIZE - AV_CODEC_CAP_AVOID_PROBING - AV_CODEC_CAP_INTRA_ONLY - AV_CODEC_CAP_LOSSLESS - AV_CODEC_CAP_HARDWARE - AV_CODEC_CAP_HYBRID - AV_CODEC_CAP_ENCODER_REORDERED_OPAQUE - - cdef enum: - FF_THREAD_FRAME - FF_THREAD_SLICE - - cdef enum: - AV_CODEC_FLAG_UNALIGNED - AV_CODEC_FLAG_QSCALE - AV_CODEC_FLAG_4MV - AV_CODEC_FLAG_OUTPUT_CORRUPT - AV_CODEC_FLAG_QPEL - AV_CODEC_FLAG_DROPCHANGED - AV_CODEC_FLAG_PASS1 - AV_CODEC_FLAG_PASS2 - AV_CODEC_FLAG_LOOP_FILTER - AV_CODEC_FLAG_GRAY - AV_CODEC_FLAG_PSNR - AV_CODEC_FLAG_TRUNCATED - AV_CODEC_FLAG_INTERLACED_DCT - AV_CODEC_FLAG_LOW_DELAY - AV_CODEC_FLAG_GLOBAL_HEADER - AV_CODEC_FLAG_BITEXACT - AV_CODEC_FLAG_AC_PRED - AV_CODEC_FLAG_INTERLACED_ME - AV_CODEC_FLAG_CLOSED_GOP - - cdef enum: - AV_CODEC_FLAG2_FAST - AV_CODEC_FLAG2_NO_OUTPUT - AV_CODEC_FLAG2_LOCAL_HEADER - AV_CODEC_FLAG2_DROP_FRAME_TIMECODE - AV_CODEC_FLAG2_CHUNKS - AV_CODEC_FLAG2_IGNORE_CROP - AV_CODEC_FLAG2_SHOW_ALL - AV_CODEC_FLAG2_EXPORT_MVS - AV_CODEC_FLAG2_SKIP_MANUAL - AV_CODEC_FLAG2_RO_FLUSH_NOOP - - cdef enum: - AV_PKT_FLAG_KEY - AV_PKT_FLAG_CORRUPT - - cdef enum: - AV_FRAME_FLAG_CORRUPT - - cdef enum: - FF_COMPLIANCE_VERY_STRICT - FF_COMPLIANCE_STRICT - FF_COMPLIANCE_NORMAL - FF_COMPLIANCE_UNOFFICIAL - FF_COMPLIANCE_EXPERIMENTAL - - cdef enum AVCodecID: - AV_CODEC_ID_NONE - AV_CODEC_ID_MPEG2VIDEO - AV_CODEC_ID_MPEG1VIDEO - - cdef enum AVDiscard: - AVDISCARD_NONE - AVDISCARD_DEFAULT - AVDISCARD_NONREF - AVDISCARD_BIDIR - AVDISCARD_NONINTRA - AVDISCARD_NONKEY - AVDISCARD_ALL - - cdef struct AVCodec: - - char *name - char *long_name - AVMediaType type - AVCodecID id - - int capabilities - - AVRational* supported_framerates - AVSampleFormat* sample_fmts - AVPixelFormat* pix_fmts - int* supported_samplerates - - AVClass *priv_class - - - cdef int av_codec_is_encoder(AVCodec*) - cdef int av_codec_is_decoder(AVCodec*) - - cdef struct AVCodecDescriptor: - AVCodecID id - char *name - char *long_name - int props - char **mime_types - - AVCodecDescriptor* avcodec_descriptor_get(AVCodecID) - - - cdef struct AVCodecContext: - - AVClass *av_class - - AVMediaType codec_type - char codec_name[32] - unsigned int codec_tag - AVCodecID codec_id - - int flags - int flags2 - - int thread_count - int thread_type - - int profile - AVDiscard skip_frame - - AVFrame* coded_frame - - int bit_rate - - int bit_rate_tolerance - int mb_decision - - int global_quality - int compression_level - - int frame_number - - int qmin - int qmax - int rc_max_rate - int rc_min_rate - int rc_buffer_size - float rc_max_available_vbv_use - float rc_min_vbv_overflow_use - - AVRational framerate - AVRational time_base - int ticks_per_frame - - int extradata_size - uint8_t *extradata - - int delay - - AVCodec *codec - - # Video. - int width - int height - int coded_width - int coded_height - - AVPixelFormat pix_fmt - AVRational sample_aspect_ratio - int gop_size # The number of pictures in a group of pictures, or 0 for intra_only. - int max_b_frames - int has_b_frames - - # Audio. - AVSampleFormat sample_fmt - int sample_rate - int channels - int frame_size - int channel_layout - - #: .. todo:: ``get_buffer`` is deprecated for get_buffer2 in newer versions of FFmpeg. - int get_buffer(AVCodecContext *ctx, AVFrame *frame) - void release_buffer(AVCodecContext *ctx, AVFrame *frame) - - # User Data - void *opaque - - cdef AVCodecContext* avcodec_alloc_context3(AVCodec *codec) - cdef void avcodec_free_context(AVCodecContext **ctx) - - cdef AVClass* avcodec_get_class() - cdef int avcodec_copy_context(AVCodecContext *dst, const AVCodecContext *src) - - cdef struct AVCodecDescriptor: - AVCodecID id - AVMediaType type - char *name - char *long_name - int props - - cdef AVCodec* avcodec_find_decoder(AVCodecID id) - cdef AVCodec* avcodec_find_encoder(AVCodecID id) - - cdef AVCodec* avcodec_find_decoder_by_name(char *name) - cdef AVCodec* avcodec_find_encoder_by_name(char *name) - - cdef const AVCodec* av_codec_iterate(void **opaque) - - cdef AVCodecDescriptor* avcodec_descriptor_get (AVCodecID id) - cdef AVCodecDescriptor* avcodec_descriptor_get_by_name (char *name) - - cdef char* avcodec_get_name(AVCodecID id) - - cdef char* av_get_profile_name(AVCodec *codec, int profile) - - cdef int avcodec_open2( - AVCodecContext *ctx, - AVCodec *codec, - AVDictionary **options, - ) - - cdef int avcodec_is_open(AVCodecContext *ctx ) - cdef int avcodec_close(AVCodecContext *ctx) - - cdef int AV_NUM_DATA_POINTERS - - cdef enum AVFrameSideDataType: - AV_FRAME_DATA_PANSCAN - AV_FRAME_DATA_A53_CC - AV_FRAME_DATA_STEREO3D - AV_FRAME_DATA_MATRIXENCODING - AV_FRAME_DATA_DOWNMIX_INFO - AV_FRAME_DATA_REPLAYGAIN - AV_FRAME_DATA_DISPLAYMATRIX - AV_FRAME_DATA_AFD - AV_FRAME_DATA_MOTION_VECTORS - AV_FRAME_DATA_SKIP_SAMPLES - AV_FRAME_DATA_AUDIO_SERVICE_TYPE - AV_FRAME_DATA_MASTERING_DISPLAY_METADATA - AV_FRAME_DATA_GOP_TIMECODE - AV_FRAME_DATA_SPHERICAL - AV_FRAME_DATA_CONTENT_LIGHT_LEVEL - AV_FRAME_DATA_ICC_PROFILE - AV_FRAME_DATA_QP_TABLE_PROPERTIES - AV_FRAME_DATA_QP_TABLE_DATA - - cdef struct AVFrameSideData: - AVFrameSideDataType type - uint8_t *data - int size - AVDictionary *metadata - - # See: http://ffmpeg.org/doxygen/trunk/structAVFrame.html - cdef struct AVFrame: - uint8_t *data[4]; - int linesize[4]; - uint8_t **extended_data - - int format # Should be AVPixelFormat or AVSampleFormat - int key_frame # 0 or 1. - AVPictureType pict_type - - int interlaced_frame # 0 or 1. - - int width - int height - - int nb_side_data - AVFrameSideData **side_data - - int nb_samples # Audio samples - int sample_rate # Audio Sample rate - int channels # Number of audio channels - int channel_layout # Audio channel_layout - - int64_t pts - int64_t pkt_dts - - int pkt_size - - uint8_t **base - void *opaque - AVDictionary *metadata - int flags - int decode_error_flags - - - cdef AVFrame* avcodec_alloc_frame() - - cdef struct AVPacket: - - int64_t pts - int64_t dts - uint8_t *data - - int size - int stream_index - int flags - - int duration - - int64_t pos - - void (*destruct)(AVPacket*) - - - cdef int avcodec_fill_audio_frame( - AVFrame *frame, - int nb_channels, - AVSampleFormat sample_fmt, - uint8_t *buf, - int buf_size, - int align - ) - - cdef void avcodec_free_frame(AVFrame **frame) - - cdef void av_init_packet(AVPacket*) - cdef int av_new_packet(AVPacket*, int) - cdef int av_packet_ref(AVPacket *dst, const AVPacket *src) - cdef void av_packet_unref(AVPacket *pkt) - cdef void av_packet_rescale_ts(AVPacket *pkt, AVRational src_tb, AVRational dst_tb) - - cdef enum AVSubtitleType: - SUBTITLE_NONE - SUBTITLE_BITMAP - SUBTITLE_TEXT - SUBTITLE_ASS - - cdef struct AVSubtitleRect: - int x - int y - int w - int h - int nb_colors - uint8_t *data[4]; - int linesize[4]; - AVSubtitleType type - char *text - char *ass - int flags - - cdef struct AVSubtitle: - uint16_t format - uint32_t start_display_time - uint32_t end_display_time - unsigned int num_rects - AVSubtitleRect **rects - int64_t pts - - cdef int avcodec_decode_subtitle2( - AVCodecContext *ctx, - AVSubtitle *sub, - int *done, - AVPacket *pkt, - ) - - cdef int avcodec_encode_subtitle( - AVCodecContext *avctx, - uint8_t *buf, - int buf_size, - AVSubtitle *sub - ) - - cdef void avsubtitle_free(AVSubtitle*) - - cdef void avcodec_get_frame_defaults(AVFrame* frame) - - cdef void avcodec_flush_buffers(AVCodecContext *ctx) - - # TODO: avcodec_default_get_buffer is deprecated for avcodec_default_get_buffer2 in newer versions of FFmpeg - cdef int avcodec_default_get_buffer(AVCodecContext *ctx, AVFrame *frame) - cdef void avcodec_default_release_buffer(AVCodecContext *ctx, AVFrame *frame) - - # === New-style Transcoding - cdef int avcodec_send_packet(AVCodecContext *avctx, AVPacket *packet) - cdef int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame) - cdef int avcodec_send_frame(AVCodecContext *avctx, AVFrame *frame) - cdef int avcodec_receive_packet(AVCodecContext *avctx, AVPacket *avpkt) - - # === Parsers - - cdef struct AVCodecParser: - int codec_ids[5] - - cdef AVCodecParser* av_parser_next(AVCodecParser *c) - - cdef struct AVCodecParserContext: - pass - - cdef AVCodecParserContext *av_parser_init(int codec_id) - cdef int av_parser_parse2( - AVCodecParserContext *s, - AVCodecContext *avctx, - uint8_t **poutbuf, int *poutbuf_size, - const uint8_t *buf, int buf_size, - int64_t pts, int64_t dts, - int64_t pos - ) - cdef int av_parser_change( - AVCodecParserContext *s, - AVCodecContext *avctx, - uint8_t **poutbuf, int *poutbuf_size, - const uint8_t *buf, int buf_size, - int keyframe - ) - cdef void av_parser_close(AVCodecParserContext *s) - - - cdef struct AVCodecParameters: - pass - - cdef int avcodec_parameters_from_context( - AVCodecParameters *par, - const AVCodecContext *codec, - ) - diff --git a/include/libavdevice/avdevice.pxd b/include/libavdevice/avdevice.pxd deleted file mode 100644 index bc9b4adea..000000000 --- a/include/libavdevice/avdevice.pxd +++ /dev/null @@ -1,12 +0,0 @@ - -cdef extern from "libavdevice/avdevice.h" nogil: - - cdef int avdevice_version() - cdef char* avdevice_configuration() - cdef char* avdevice_license() - void avdevice_register_all() - - AVInputFormat * av_input_audio_device_next(AVInputFormat *d) - AVInputFormat * av_input_video_device_next(AVInputFormat *d) - AVOutputFormat * av_output_audio_device_next(AVOutputFormat *d) - AVOutputFormat * av_output_video_device_next(AVOutputFormat *d) diff --git a/include/libavfilter/avfilter.pxd b/include/libavfilter/avfilter.pxd deleted file mode 100644 index d6832e364..000000000 --- a/include/libavfilter/avfilter.pxd +++ /dev/null @@ -1,77 +0,0 @@ - -cdef extern from "libavfilter/avfilter.h" nogil: - - cdef int avfilter_version() - cdef char* avfilter_configuration() - cdef char* avfilter_license() - - cdef struct AVFilterPad: - # This struct is opaque. - pass - - const char* avfilter_pad_get_name(const AVFilterPad *pads, int index) - AVMediaType avfilter_pad_get_type(const AVFilterPad *pads, int index) - - cdef struct AVFilter: - - AVClass *priv_class - - const char *name - const char *description - - const int flags - - const AVFilterPad *inputs - const AVFilterPad *outputs - int (*process_command)(AVFilterContext *, const char *cmd, const char *arg, char *res, int res_len, int flags) - - cdef enum: - AVFILTER_FLAG_DYNAMIC_INPUTS - AVFILTER_FLAG_DYNAMIC_OUTPUTS - AVFILTER_FLAG_SLICE_THREADS - AVFILTER_FLAG_SUPPORT_TIMELINE_GENERIC - AVFILTER_FLAG_SUPPORT_TIMELINE_INTERNAL - - cdef AVFilter* avfilter_get_by_name(const char *name) - cdef const AVFilter* av_filter_iterate(void **opaque) - - cdef struct AVFilterLink # Defined later. - - cdef struct AVFilterContext: - - AVClass *av_class - AVFilter *filter - - char *name - - unsigned int nb_inputs - AVFilterPad *input_pads - AVFilterLink **inputs - - unsigned int nb_outputs - AVFilterPad *output_pads - AVFilterLink **outputs - - cdef int avfilter_init_str(AVFilterContext *ctx, const char *args) - cdef int avfilter_init_dict(AVFilterContext *ctx, AVDictionary **options) - cdef void avfilter_free(AVFilterContext*) - cdef AVClass* avfilter_get_class() - - cdef struct AVFilterLink: - - AVFilterContext *src - AVFilterPad *srcpad - AVFilterContext *dst - AVFilterPad *dstpad - - AVMediaType Type - int w - int h - AVRational sample_aspect_ratio - uint64_t channel_layout - int sample_rate - int format - AVRational time_base - - # custom - cdef set pyav_get_available_filters() diff --git a/include/libavfilter/avfiltergraph.pxd b/include/libavfilter/avfiltergraph.pxd deleted file mode 100644 index db9717a50..000000000 --- a/include/libavfilter/avfiltergraph.pxd +++ /dev/null @@ -1,51 +0,0 @@ - -cdef extern from "libavfilter/avfilter.h" nogil: - - cdef struct AVFilterGraph: - int nb_filters - AVFilterContext **filters - - cdef struct AVFilterInOut: - char *name - AVFilterContext *filter_ctx - int pad_idx - AVFilterInOut *next - - - cdef AVFilterGraph* avfilter_graph_alloc() - cdef void avfilter_graph_free(AVFilterGraph **ptr) - - cdef int avfilter_graph_parse2( - AVFilterGraph *graph, - const char *filter_str, - AVFilterInOut **inputs, - AVFilterInOut **outputs - ) - - cdef AVFilterContext* avfilter_graph_alloc_filter( - AVFilterGraph *graph, - const AVFilter *filter, - const char *name - ) - - cdef int avfilter_graph_create_filter( - AVFilterContext **filt_ctx, - AVFilter *filt, - const char *name, - const char *args, - void *opaque, - AVFilterGraph *graph_ctx - ) - - cdef int avfilter_link( - AVFilterContext *src, - unsigned int srcpad, - AVFilterContext *dst, - unsigned int dstpad - ) - - cdef int avfilter_graph_config(AVFilterGraph *graph, void *logctx) - - cdef char* avfilter_graph_dump(AVFilterGraph *graph, const char *options) - - cdef void avfilter_inout_free(AVFilterInOut **inout_list) diff --git a/include/libavfilter/buffersink.pxd b/include/libavfilter/buffersink.pxd deleted file mode 100644 index 84ea56c68..000000000 --- a/include/libavfilter/buffersink.pxd +++ /dev/null @@ -1,6 +0,0 @@ -cdef extern from "libavfilter/buffersink.h" nogil: - - int av_buffersink_get_frame( - AVFilterContext *ctx, - AVFrame *frame - ) diff --git a/include/libavfilter/buffersrc.pxd b/include/libavfilter/buffersrc.pxd deleted file mode 100644 index db057662a..000000000 --- a/include/libavfilter/buffersrc.pxd +++ /dev/null @@ -1,6 +0,0 @@ -cdef extern from "libavfilter/buffersrc.h" nogil: - - int av_buffersrc_write_frame( - AVFilterContext *ctx, - const AVFrame *frame - ) diff --git a/include/libavutil/avutil.pxd b/include/libavutil/avutil.pxd deleted file mode 100644 index 50e6bfffd..000000000 --- a/include/libavutil/avutil.pxd +++ /dev/null @@ -1,327 +0,0 @@ -from libc.stdint cimport int64_t, uint8_t, uint64_t - - -cdef extern from "libavutil/mathematics.h" nogil: - pass - -cdef extern from "libavutil/rational.h" nogil: - cdef int av_reduce(int *dst_num, int *dst_den, int64_t num, int64_t den, int64_t max) - -cdef extern from "libavutil/avutil.h" nogil: - - cdef int avutil_version() - cdef char* avutil_configuration() - cdef char* avutil_license() - - cdef enum AVPictureType: - AV_PICTURE_TYPE_NONE - AV_PICTURE_TYPE_I - AV_PICTURE_TYPE_P - AV_PICTURE_TYPE_B - AV_PICTURE_TYPE_S - AV_PICTURE_TYPE_SI - AV_PICTURE_TYPE_SP - AV_PICTURE_TYPE_BI - - cdef enum AVPixelFormat: - AV_PIX_FMT_NONE - AV_PIX_FMT_YUV420P - AV_PIX_FMT_RGB24 - PIX_FMT_RGB24 - PIX_FMT_RGBA - - cdef enum AVRounding: - AV_ROUND_ZERO - AV_ROUND_INF - AV_ROUND_DOWN - AV_ROUND_UP - AV_ROUND_NEAR_INF - # This is nice, but only in FFMpeg: - # AV_ROUND_PASS_MINMAX - - - cdef double M_PI - - cdef void* av_malloc(size_t size) - cdef void *av_calloc(size_t nmemb, size_t size) - cdef void *av_realloc(void *ptr, size_t size) - - cdef void av_freep(void *ptr) - - cdef int av_get_bytes_per_sample(AVSampleFormat sample_fmt) - - cdef int av_samples_get_buffer_size( - int *linesize, - int nb_channels, - int nb_samples, - AVSampleFormat sample_fmt, - int align - ) - - # See: http://ffmpeg.org/doxygen/trunk/structAVRational.html - ctypedef struct AVRational: - int num - int den - - cdef AVRational AV_TIME_BASE_Q - - # Rescales from one time base to another - cdef int64_t av_rescale_q( - int64_t a, # time stamp - AVRational bq, # source time base - AVRational cq # target time base - ) - - # Rescale a 64-bit integer with specified rounding. - # A simple a*b/c isn't possible as it can overflow - cdef int64_t av_rescale_rnd( - int64_t a, - int64_t b, - int64_t c, - int r # should be AVRounding, but then we can't use bitwise logic. - ) - - cdef int64_t av_rescale_q_rnd( - int64_t a, - AVRational bq, - AVRational cq, - int r # should be AVRounding, but then we can't use bitwise logic. - ) - - cdef int64_t av_rescale( - int64_t a, - int64_t b, - int64_t c - ) - - cdef char* av_strdup(char *s) - - cdef int av_opt_set_int( - void *obj, - char *name, - int64_t value, - int search_flags - ) - - cdef const char* av_get_media_type_string(AVMediaType media_type) - -cdef extern from "libavutil/pixdesc.h" nogil: - - # See: http://ffmpeg.org/doxygen/trunk/structAVComponentDescriptor.html - cdef struct AVComponentDescriptor: - unsigned int plane - unsigned int step - unsigned int offset - unsigned int shift - unsigned int depth - - cdef enum AVPixFmtFlags: - AV_PIX_FMT_FLAG_BE - AV_PIX_FMT_FLAG_PAL - AV_PIX_FMT_FLAG_BITSTREAM - AV_PIX_FMT_FLAG_HWACCEL - AV_PIX_FMT_FLAG_PLANAR - AV_PIX_FMT_FLAG_RGB - AV_PIX_FMT_FLAG_PSEUDOPAL - AV_PIX_FMT_FLAG_ALPHA - AV_PIX_FMT_FLAG_BAYER - AV_PIX_FMT_FLAG_FLOAT - - # See: http://ffmpeg.org/doxygen/trunk/structAVPixFmtDescriptor.html - cdef struct AVPixFmtDescriptor: - const char *name - uint8_t nb_components - uint8_t log2_chroma_w - uint8_t log2_chroma_h - uint8_t flags - AVComponentDescriptor comp[4] - - cdef AVPixFmtDescriptor* av_pix_fmt_desc_get(AVPixelFormat pix_fmt) - cdef AVPixFmtDescriptor* av_pix_fmt_desc_next(AVPixFmtDescriptor *prev) - - cdef char * av_get_pix_fmt_name(AVPixelFormat pix_fmt) - cdef AVPixelFormat av_get_pix_fmt(char* name) - - int av_get_bits_per_pixel(AVPixFmtDescriptor *pixdesc) - int av_get_padded_bits_per_pixel(AVPixFmtDescriptor *pixdesc) - - - -cdef extern from "libavutil/channel_layout.h" nogil: - - # Layouts. - cdef uint64_t av_get_channel_layout(char* name) - cdef int av_get_channel_layout_nb_channels(uint64_t channel_layout) - cdef int64_t av_get_default_channel_layout(int nb_channels) - cdef void av_get_channel_layout_string( - char* buff, - int buf_size, - int nb_channels, - uint64_t channel_layout - ) - - # Channels. - cdef uint64_t av_channel_layout_extract_channel(uint64_t layout, int index) - cdef char* av_get_channel_name(uint64_t channel) - cdef char* av_get_channel_description(uint64_t channel) - - - - -cdef extern from "libavutil/audio_fifo.h" nogil: - - cdef struct AVAudioFifo: - pass - - cdef void av_audio_fifo_free(AVAudioFifo *af) - - cdef AVAudioFifo* av_audio_fifo_alloc( - AVSampleFormat sample_fmt, - int channels, - int nb_samples - ) - - cdef int av_audio_fifo_write( - AVAudioFifo *af, - void **data, - int nb_samples - ) - - cdef int av_audio_fifo_read( - AVAudioFifo *af, - void **data, - int nb_samples - ) - - cdef int av_audio_fifo_size(AVAudioFifo *af) - cdef int av_audio_fifo_space (AVAudioFifo *af) - - -cdef extern from "stdarg.h" nogil: - - # For logging. Should really be in another PXD. - ctypedef struct va_list: - pass - - -cdef extern from "Python.h" nogil: - - # For logging. See av/logging.pyx for an explanation. - cdef int Py_AddPendingCall(void *, void *) - void PyErr_PrintEx(int set_sys_last_vars) - int Py_IsInitialized() - void PyErr_Display(object, object, object) - - -cdef extern from "libavutil/opt.h" nogil: - - cdef enum AVOptionType: - - AV_OPT_TYPE_FLAGS - AV_OPT_TYPE_INT - AV_OPT_TYPE_INT64 - AV_OPT_TYPE_DOUBLE - AV_OPT_TYPE_FLOAT - AV_OPT_TYPE_STRING - AV_OPT_TYPE_RATIONAL - AV_OPT_TYPE_BINARY - AV_OPT_TYPE_DICT - #AV_OPT_TYPE_UINT64 # since FFmpeg 3.3 - AV_OPT_TYPE_CONST - AV_OPT_TYPE_IMAGE_SIZE - AV_OPT_TYPE_PIXEL_FMT - AV_OPT_TYPE_SAMPLE_FMT - AV_OPT_TYPE_VIDEO_RATE - AV_OPT_TYPE_DURATION - AV_OPT_TYPE_COLOR - AV_OPT_TYPE_CHANNEL_LAYOUT - AV_OPT_TYPE_BOOL - - cdef struct AVOption_default_val: - int64_t i64 - double dbl - const char *str - AVRational q - - cdef enum: - AV_OPT_FLAG_ENCODING_PARAM - AV_OPT_FLAG_DECODING_PARAM - AV_OPT_FLAG_AUDIO_PARAM - AV_OPT_FLAG_VIDEO_PARAM - AV_OPT_FLAG_SUBTITLE_PARAM - AV_OPT_FLAG_EXPORT - AV_OPT_FLAG_READONLY - AV_OPT_FLAG_FILTERING_PARAM - - cdef struct AVOption: - - const char *name - const char *help - AVOptionType type - int offset - - AVOption_default_val default_val - - double min - double max - int flags - const char *unit - - -cdef extern from "libavutil/imgutils.h" nogil: - - cdef int av_image_alloc( - uint8_t *pointers[4], - int linesizes[4], - int width, - int height, - AVPixelFormat pix_fmt, - int align - ) - - -cdef extern from "libavutil/log.h" nogil: - - cdef enum AVClassCategory: - AV_CLASS_CATEGORY_NA - AV_CLASS_CATEGORY_INPUT - AV_CLASS_CATEGORY_OUTPUT - AV_CLASS_CATEGORY_MUXER - AV_CLASS_CATEGORY_DEMUXER - AV_CLASS_CATEGORY_ENCODER - AV_CLASS_CATEGORY_DECODER - AV_CLASS_CATEGORY_FILTER - AV_CLASS_CATEGORY_BITSTREAM_FILTER - AV_CLASS_CATEGORY_SWSCALER - AV_CLASS_CATEGORY_SWRESAMPLER - AV_CLASS_CATEGORY_NB - - cdef struct AVClass: - - const char *class_name - const char *(*item_name)(void*) nogil - - AVClassCategory category - int parent_log_context_offset - - const AVOption *option - - cdef enum: - AV_LOG_QUIET - AV_LOG_PANIC - AV_LOG_FATAL - AV_LOG_ERROR - AV_LOG_WARNING - AV_LOG_INFO - AV_LOG_VERBOSE - AV_LOG_DEBUG - AV_LOG_TRACE - AV_LOG_MAX_OFFSET - - # Send a log. - void av_log(void *ptr, int level, const char *fmt, ...) - - # Get the logs. - ctypedef void(*av_log_callback)(void *, int, const char *, va_list) - void av_log_default_callback(void *, int, const char *, va_list) - void av_log_set_callback (av_log_callback callback) diff --git a/include/libavutil/channel_layout.pxd b/include/libavutil/channel_layout.pxd deleted file mode 100644 index 1459fbd22..000000000 --- a/include/libavutil/channel_layout.pxd +++ /dev/null @@ -1,11 +0,0 @@ -cdef extern from "libavutil/channel_layout.h" nogil: - - # This is not a comprehensive list. - cdef uint64_t AV_CH_LAYOUT_MONO - cdef uint64_t AV_CH_LAYOUT_STEREO - cdef uint64_t AV_CH_LAYOUT_2POINT1 - cdef uint64_t AV_CH_LAYOUT_4POINT0 - cdef uint64_t AV_CH_LAYOUT_5POINT0_BACK - cdef uint64_t AV_CH_LAYOUT_5POINT1_BACK - cdef uint64_t AV_CH_LAYOUT_6POINT1 - cdef uint64_t AV_CH_LAYOUT_7POINT1 diff --git a/include/libavutil/dict.pxd b/include/libavutil/dict.pxd deleted file mode 100644 index c2b141101..000000000 --- a/include/libavutil/dict.pxd +++ /dev/null @@ -1,38 +0,0 @@ -cdef extern from "libavutil/dict.h" nogil: - - # See: http://ffmpeg.org/doxygen/trunk/structAVDictionary.html - ctypedef struct AVDictionary: - pass - - cdef void av_dict_free(AVDictionary **) - - # See: http://ffmpeg.org/doxygen/trunk/structAVDictionaryEntry.html - ctypedef struct AVDictionaryEntry: - char *key - char *value - - cdef int AV_DICT_IGNORE_SUFFIX - - cdef AVDictionaryEntry* av_dict_get( - AVDictionary *dict, - char *key, - AVDictionaryEntry *prev, - int flags, - ) - - cdef int av_dict_set( - AVDictionary **pm, - const char *key, - const char *value, - int flags - ) - - cdef int av_dict_count( - AVDictionary *m - ) - - cdef int av_dict_copy( - AVDictionary **dst, - AVDictionary *src, - int flags - ) diff --git a/include/libavutil/error.pxd b/include/libavutil/error.pxd deleted file mode 100644 index 8919c54b7..000000000 --- a/include/libavutil/error.pxd +++ /dev/null @@ -1,44 +0,0 @@ -cdef extern from "libavutil/error.h" nogil: - - # Not actually from here, but whatever. - cdef int ENOMEM - cdef int EAGAIN - - - cdef int AVERROR_BSF_NOT_FOUND - cdef int AVERROR_BUG - cdef int AVERROR_BUFFER_TOO_SMALL - cdef int AVERROR_DECODER_NOT_FOUND - cdef int AVERROR_DEMUXER_NOT_FOUND - cdef int AVERROR_ENCODER_NOT_FOUND - cdef int AVERROR_EOF - cdef int AVERROR_EXIT - cdef int AVERROR_EXTERNAL - cdef int AVERROR_FILTER_NOT_FOUND - cdef int AVERROR_INVALIDDATA - cdef int AVERROR_MUXER_NOT_FOUND - cdef int AVERROR_OPTION_NOT_FOUND - cdef int AVERROR_PATCHWELCOME - cdef int AVERROR_PROTOCOL_NOT_FOUND - cdef int AVERROR_UNKNOWN - cdef int AVERROR_EXPERIMENTAL - cdef int AVERROR_INPUT_CHANGED - cdef int AVERROR_OUTPUT_CHANGED - - cdef int AVERROR_HTTP_BAD_REQUEST - cdef int AVERROR_HTTP_UNAUTHORIZED - cdef int AVERROR_HTTP_FORBIDDEN - cdef int AVERROR_HTTP_NOT_FOUND - cdef int AVERROR_HTTP_OTHER_4XX - cdef int AVERROR_HTTP_SERVER_ERROR - - cdef int AVERROR_NOMEM "AVERROR(ENOMEM)" - - # cdef int FFERRTAG(int, int, int, int) - - cdef int AVERROR(int error) - - cdef int AV_ERROR_MAX_STRING_SIZE - - cdef int av_strerror(int errno, char *output, size_t output_size) - cdef char* av_err2str(int errnum) diff --git a/include/libavutil/frame.pxd b/include/libavutil/frame.pxd deleted file mode 100644 index aa8dc3a00..000000000 --- a/include/libavutil/frame.pxd +++ /dev/null @@ -1,14 +0,0 @@ -cdef extern from "libavutil/frame.h" nogil: - - cdef AVFrame* av_frame_alloc() - cdef void av_frame_free(AVFrame**) - cdef int av_frame_ref(AVFrame *dst, const AVFrame *src) - cdef AVFrame* av_frame_clone(const AVFrame *src) - cdef void av_frame_unref(AVFrame *frame) - cdef void av_frame_move_ref(AVFrame *dst, AVFrame *src) - cdef int av_frame_get_buffer(AVFrame *frame, int align) - cdef int av_frame_is_writable(AVFrame *frame) - cdef int av_frame_make_writable(AVFrame *frame) - cdef int av_frame_copy(AVFrame *dst, const AVFrame *src) - cdef int av_frame_copy_props(AVFrame *dst, const AVFrame *src) - cdef AVFrameSideData* av_frame_get_side_data(AVFrame *frame, AVFrameSideDataType type) diff --git a/include/libavutil/motion_vector.pxd b/include/libavutil/motion_vector.pxd deleted file mode 100644 index bcf61c0d1..000000000 --- a/include/libavutil/motion_vector.pxd +++ /dev/null @@ -1,22 +0,0 @@ -from libc.stdint cimport ( - uint8_t, int8_t, - uint16_t, int16_t, - uint32_t, int32_t, - uint64_t, int64_t -) - - -cdef extern from "libavutil/motion_vector.h" nogil: - - cdef struct AVMotionVector: - int32_t source - uint8_t w - uint8_t h - int16_t src_x - int16_t src_y - int16_t dst_x - int16_t dst_y - uint64_t flags - int32_t motion_x - int32_t motion_y - uint16_t motion_scale diff --git a/include/libavutil/samplefmt.pxd b/include/libavutil/samplefmt.pxd deleted file mode 100644 index 867367ee1..000000000 --- a/include/libavutil/samplefmt.pxd +++ /dev/null @@ -1,63 +0,0 @@ -cdef extern from "libavutil/samplefmt.h" nogil: - - cdef enum AVSampleFormat: - AV_SAMPLE_FMT_NONE - AV_SAMPLE_FMT_U8 - AV_SAMPLE_FMT_S16 - AV_SAMPLE_FMT_S32 - AV_SAMPLE_FMT_FLT - AV_SAMPLE_FMT_DBL - AV_SAMPLE_FMT_U8P - AV_SAMPLE_FMT_S16P - AV_SAMPLE_FMT_S32P - AV_SAMPLE_FMT_FLTP - AV_SAMPLE_FMT_DBLP - AV_SAMPLE_FMT_NB # Number. - - # Find by name. - cdef AVSampleFormat av_get_sample_fmt(char* name) - - # Inspection. - cdef char * av_get_sample_fmt_name(AVSampleFormat sample_fmt) - cdef int av_get_bytes_per_sample(AVSampleFormat sample_fmt) - cdef int av_sample_fmt_is_planar(AVSampleFormat sample_fmt) - - # Alternative forms. - cdef AVSampleFormat av_get_packed_sample_fmt(AVSampleFormat sample_fmt) - cdef AVSampleFormat av_get_planar_sample_fmt(AVSampleFormat sample_fmt) - - cdef int av_samples_alloc( - uint8_t** audio_data, - int* linesize, - int nb_channels, - int nb_samples, - AVSampleFormat sample_fmt, - int align - ) - - cdef int av_samples_get_buffer_size( - int *linesize, - int nb_channels, - int nb_samples, - AVSampleFormat sample_fmt, - int align - ) - - - cdef int av_samples_fill_arrays( - uint8_t **audio_data, - int *linesize, - const uint8_t *buf, - int nb_channels, - int nb_samples, - AVSampleFormat sample_fmt, - int align - ) - - cdef int av_samples_set_silence( - uint8_t **audio_data, - int offset, - int nb_samples, - int nb_channels, - AVSampleFormat sample_fmt - ) diff --git a/include/libswresample/swresample.pxd b/include/libswresample/swresample.pxd deleted file mode 100644 index 703310139..000000000 --- a/include/libswresample/swresample.pxd +++ /dev/null @@ -1,39 +0,0 @@ -from libc.stdint cimport int64_t, uint8_t - -cdef extern from "libswresample/swresample.h" nogil: - - cdef int swresample_version() - cdef char* swresample_configuration() - cdef char* swresample_license() - - - cdef struct SwrContext: - pass - - cdef SwrContext* swr_alloc_set_opts( - SwrContext *ctx, - int64_t out_ch_layout, - AVSampleFormat out_sample_fmt, - int out_sample_rate, - int64_t in_ch_layout, - AVSampleFormat in_sample_fmt, - int in_sample_rate, - int log_offset, - void *log_ctx #logging context, can be NULL - ) - - cdef int swr_convert( - SwrContext *ctx, - uint8_t ** out_buffer, - int out_count, - uint8_t **in_buffer, - int in_count - ) - # Gets the delay the next input sample will - # experience relative to the next output sample. - cdef int64_t swr_get_delay(SwrContext *s, int64_t base) - - cdef SwrContext* swr_alloc() - cdef int swr_init(SwrContext* ctx) - cdef void swr_free(SwrContext **ctx) - cdef void swr_close(SwrContext *ctx) diff --git a/include/libswscale/swscale.pxd b/include/libswscale/swscale.pxd deleted file mode 100644 index af7f7d134..000000000 --- a/include/libswscale/swscale.pxd +++ /dev/null @@ -1,98 +0,0 @@ - -cdef extern from "libswscale/swscale.h" nogil: - - cdef int swscale_version() - cdef char* swscale_configuration() - cdef char* swscale_license() - - # See: http://ffmpeg.org/doxygen/trunk/structSwsContext.html - cdef struct SwsContext: - pass - - # See: http://ffmpeg.org/doxygen/trunk/structSwsFilter.html - cdef struct SwsFilter: - pass - - # Flags. - cdef int SWS_FAST_BILINEAR - cdef int SWS_BILINEAR - cdef int SWS_BICUBIC - cdef int SWS_X - cdef int SWS_POINT - cdef int SWS_AREA - cdef int SWS_BICUBLIN - cdef int SWS_GAUSS - cdef int SWS_SINC - cdef int SWS_LANCZOS - cdef int SWS_SPLINE - - cdef int SWS_CS_ITU709 - cdef int SWS_CS_FCC - cdef int SWS_CS_ITU601 - cdef int SWS_CS_ITU624 - cdef int SWS_CS_SMPTE170M - cdef int SWS_CS_SMPTE240M - cdef int SWS_CS_DEFAULT - - cdef SwsContext* sws_getContext( - int src_width, - int src_height, - AVPixelFormat src_format, - int dst_width, - int dst_height, - AVPixelFormat dst_format, - int flags, - SwsFilter *src_filter, - SwsFilter *dst_filter, - double *param, - ) - - cdef int sws_scale( - SwsContext *ctx, - unsigned char **src_slice, - int *src_stride, - int src_slice_y, - int src_slice_h, - unsigned char **dst_slice, - int *dst_stride, - ) - - cdef void sws_freeContext(SwsContext *ctx) - - cdef SwsContext *sws_getCachedContext( - SwsContext *context, - int src_width, - int src_height, - AVPixelFormat src_format, - int dst_width, - int dst_height, - AVPixelFormat dst_format, - int flags, - SwsFilter *src_filter, - SwsFilter *dst_filter, - double *param, - ) - - cdef int* sws_getCoefficients(int colorspace) - - cdef int sws_getColorspaceDetails( - SwsContext *context, - int **inv_table, - int *srcRange, - int **table, - int *dstRange, - int *brightness, - int *contrast, - int *saturation - ) - - cdef int sws_setColorspaceDetails( - SwsContext *context, - const int inv_table[4], - int srcRange, - const int table[4], - int dstRange, - int brightness, - int contrast, - int saturation - ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..035e4b489 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=77.0", "cython>=3.1.0,<4"] + +[project] +name = "av" +description = "Pythonic bindings for FFmpeg's libraries." +readme = "README.md" +license = "BSD-3-Clause" +authors = [ + {name = "WyattBlue", email = "wyattblue@auto-editor.com"}, + {name = "Jeremy Lainé", email = "jeremy.laine@m4x.org"}, +] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Cython", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Sound/Audio :: Conversion", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", +] +dynamic = ["version"] + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.packages.find] +include = ["av*"] + +[tool.setuptools.dynamic] +version = {attr = "av.about.__version__"} + +[project.urls] +"Bug Tracker" = "https://github.com/PyAV-Org/PyAV/issues" +"Source Code" = "https://github.com/PyAV-Org/PyAV" +homepage = "https://pyav.basswood.io" + +[project.scripts] +"pyav" = "av.__main__:main" + +[tool.isort] +profile = "black" +known_first_party = ["av"] +skip = ["av/__init__.py"] diff --git a/scratchpad/README b/scratchpad/README deleted file mode 100644 index 83d571e01..000000000 --- a/scratchpad/README +++ /dev/null @@ -1,4 +0,0 @@ -This directory is for the PyAV developers to dump partial or exprimental tests. -The contents of this directory are not guaranteed to work, or make sense in any way. - -Have fun! diff --git a/scratchpad/__init__.py b/scratchpad/__init__.py deleted file mode 100644 index ab4bc5141..000000000 --- a/scratchpad/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -import logging - -logging.basicConfig(level=logging.WARNING) diff --git a/scratchpad/audio.py b/scratchpad/audio.py deleted file mode 100644 index 950a7951c..000000000 --- a/scratchpad/audio.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import print_function -import array -import argparse -import sys -import pprint -import subprocess - -from PIL import Image -import av - - -def print_data(frame): - for i, plane in enumerate(frame.planes or ()): - data = plane.to_bytes() - print('\tPLANE %d, %d bytes' % (i, len(data))) - data = data.encode('hex') - for i in xrange(0, len(data), 128): - print('\t\t\t%s' % data[i:i + 128]) - - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('path') -arg_parser.add_argument('-p', '--play', action='store_true') -arg_parser.add_argument('-d', '--data', action='store_true') -arg_parser.add_argument('-f', '--format') -arg_parser.add_argument('-l', '--layout') -arg_parser.add_argument('-r', '--rate', type=int) -arg_parser.add_argument('-s', '--size', type=int, default=1024) -arg_parser.add_argument('-c', '--count', type=int, default=5) -args = arg_parser.parse_args() - -ffplay = None - -container = av.open(args.path) -stream = next(s for s in container.streams if s.type == 'audio') - -fifo = av.AudioFifo() if args.size else None -resampler = av.AudioResampler( - format=av.AudioFormat(args.format or stream.format.name).packed if args.format else None, - layout=int(args.layout) if args.layout and args.layout.isdigit() else args.layout, - rate=args.rate, -) if (args.format or args.layout or args.rate) else None - -read_count = 0 -fifo_count = 0 -sample_count = 0 - -for i, packet in enumerate(container.demux(stream)): - - for frame in packet.decode(): - - read_count += 1 - print('>>>> %04d' % read_count, frame) - if args.data: - print_data(frame) - - frames = [frame] - - if resampler: - for i, frame in enumerate(frames): - frame = resampler.resample(frame) - print('RESAMPLED', frame) - if args.data: - print_data(frame) - frames[i] = frame - - if fifo: - - to_process = frames - frames = [] - - for frame in to_process: - fifo.write(frame) - while frame: - frame = fifo.read(args.size) - if frame: - fifo_count += 1 - print('|||| %04d' % fifo_count, frame) - if args.data: - print_data(frame) - frames.append(frame) - - if frames and args.play: - if not ffplay: - cmd = ['ffplay', - '-f', frames[0].format.packed.container_name, - '-ar', str(args.rate or stream.rate), - '-ac', str(len(resampler.layout.channels if resampler else stream.layout.channels)), - '-vn', '-', - ] - print('PLAY', ' '.join(cmd)) - ffplay = subprocess.Popen(cmd, stdin=subprocess.PIPE) - try: - for frame in frames: - ffplay.stdin.write(frame.planes[0].to_bytes()) - except IOError as e: - print(e) - exit() - - if args.count and read_count >= args.count: - exit() diff --git a/scratchpad/audio_player.py b/scratchpad/audio_player.py deleted file mode 100644 index 7a06aba4d..000000000 --- a/scratchpad/audio_player.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import print_function -import array -import argparse -import sys -import pprint -import subprocess -import time - -from qtproxy import Q - -import av - - -parser = argparse.ArgumentParser() -parser.add_argument('path') -args = parser.parse_args() - -container = av.open(args.path) -stream = next(s for s in container.streams if s.type == 'audio') - -fifo = av.AudioFifo() -resampler = av.AudioResampler( - format=av.AudioFormat('s16').packed, - layout='stereo', - rate=48000, -) - - - -qformat = Q.AudioFormat() -qformat.setByteOrder(Q.AudioFormat.LittleEndian) -qformat.setChannelCount(2) -qformat.setCodec('audio/pcm') -qformat.setSampleRate(48000) -qformat.setSampleSize(16) -qformat.setSampleType(Q.AudioFormat.SignedInt) - -output = Q.AudioOutput(qformat) -output.setBufferSize(2 * 2 * 48000) - -device = output.start() - -print(qformat, output, device) - -def decode_iter(): - try: - for pi, packet in enumerate(container.demux(stream)): - for fi, frame in enumerate(packet.decode()): - yield pi, fi, frame - except: - return - -for pi, fi, frame in decode_iter(): - - frame = resampler.resample(frame) - print(pi, fi, frame, output.state()) - - bytes_buffered = output.bufferSize() - output.bytesFree() - us_processed = output.processedUSecs() - us_buffered = 1000000 * bytes_buffered / (2 * 16 / 8) / 48000 - print('pts: %.3f, played: %.3f, buffered: %.3f' % (frame.time or 0, us_processed / 1000000.0, us_buffered / 1000000.0)) - - - data = frame.planes[0].to_bytes() - while data: - written = device.write(data) - if written: - # print 'wrote', written - data = data[written:] - else: - # print 'did not accept data; sleeping' - time.sleep(0.033) - - if False and pi % 100 == 0: - output.reset() - print(output.state(), output.error()) - device = output.start() - - # time.sleep(0.05) - -while output.state() == Q.Audio.ActiveState: - time.sleep(0.1) diff --git a/scratchpad/average.py b/scratchpad/average.py deleted file mode 100644 index d32c40bb5..000000000 --- a/scratchpad/average.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import print_function -import argparse -import os -import sys -import pprint -import itertools - -import cv2 - -from av import open - - -parser = argparse.ArgumentParser() -parser.add_argument('-f', '--format') -parser.add_argument('-n', '--frames', type=int, default=0) -parser.add_argument('path', nargs='+') -args = parser.parse_args() - -max_size = 24 * 60 # One minute's worth. - - -def frame_iter(video): - count = 0 - streams = [s for s in video.streams if s.type == b'video'] - streams = [streams[0]] - for packet in video.demux(streams): - for frame in packet.decode(): - yield frame - count += 1 - if args.frames and count > args.frames: - return - - -for src_path in args.path: - - print('reading', src_path) - - basename = os.path.splitext(os.path.basename(src_path))[0] - dir_name = os.path.join('sandbox', basename) - if not os.path.exists(dir_name): - os.makedirs(dir_name) - - video = open(src_path, format=args.format) - frames = frame_iter(video) - - sum_ = None - - for fi, frame in enumerate(frame_iter(video)): - - if sum_ is None: - sum_ = frame.to_ndarray().astype(float) - else: - sum_ += frame.to_ndarray().astype(float) - - sum_ /= (fi + 1) - - dst_path = os.path.join('sandbox', os.path.basename(src_path) + '-avg.jpeg') - print('writing', (fi + 1), 'frames to', dst_path) - - cv2.imwrite(dst_path, sum_) diff --git a/scratchpad/cctx_decode.py b/scratchpad/cctx_decode.py deleted file mode 100644 index 67d70855e..000000000 --- a/scratchpad/cctx_decode.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import print_function -import logging - -logging.basicConfig() - - -import av -from av.codec import CodecContext, CodecParser -from av.video import VideoFrame -from av.packet import Packet - - -cc = CodecContext.create('mpeg4', 'r') -print(cc) - - -fh = open('test.mp4', 'r') - -frame_count = 0 - -while True: - - chunk = fh.read(819200) - for packet in cc.parse(chunk or None, allow_stream=True): - print(packet) - for frame in cc.decode(packet) or (): - print(frame) - img = frame.to_image() - img.save('sandbox/test.%04d.jpg' % frame_count) - frame_count += 1 - - if not chunk: - break # EOF! diff --git a/scratchpad/cctx_encode.py b/scratchpad/cctx_encode.py deleted file mode 100644 index 03bd8ef8f..000000000 --- a/scratchpad/cctx_encode.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import print_function -import logging - -from PIL import Image, ImageFont, ImageDraw - -logging.basicConfig() - -import av -from av.codec import CodecContext -from av.video import VideoFrame - -from tests.common import fate_suite - - -cc = CodecContext.create('flv', 'w') -print(cc) - -base_img = Image.open(fate_suite('png1/lena-rgb24.png')) -font = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 15) - - - -fh = open('test.flv', 'w') - -for i in range(30): - - print(i) - img = base_img.copy() - draw = ImageDraw.Draw(img) - draw.text((10, 10), "FRAME %02d" % i, font=font) - - frame = VideoFrame.from_image(img) - frame = frame.reformat(format='yuv420p') - print(' ', frame) - - packet = cc.encode(frame) - print(' ', packet) - - fh.write(str(buffer(packet))) - -print('Flushing...') - -while True: - packet = cc.encode() - if not packet: - break - print(' ', packet) - fh.write(str(buffer(packet))) - -print('Done!') diff --git a/scratchpad/container-gc.py b/scratchpad/container-gc.py deleted file mode 100644 index a5bd2130d..000000000 --- a/scratchpad/container-gc.py +++ /dev/null @@ -1,44 +0,0 @@ -import resource -import gc - - -import av -import av.datasets - -path = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4') - - -def format_bytes(n): - order = 0 - while n > 1024: - order += 1 - n //= 1024 - return '%d%sB' % (n, ('', 'k', 'M', 'G', 'T', 'P')[order]) - -after = resource.getrusage(resource.RUSAGE_SELF) - -count = 0 - -streams = [] - -while True: - - container = av.open(path) - # streams.append(container.streams.video[0]) - - del container - gc.collect() - - count += 1 - if not count % 100: - pass - # streams.clear() - # gc.collect() - - before = after - after = resource.getrusage(resource.RUSAGE_SELF) - print('{:6d} {} ({})'.format( - count, - format_bytes(after.ru_maxrss), - format_bytes(after.ru_maxrss - before.ru_maxrss), - )) diff --git a/scratchpad/decode.py b/scratchpad/decode.py deleted file mode 100644 index 0dfbf2df9..000000000 --- a/scratchpad/decode.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import print_function -import array -import argparse -import logging -import sys -import pprint -import subprocess - -from PIL import Image - -from av import open, time_base - - -logging.basicConfig(level=logging.DEBUG) - - -def format_time(time, time_base): - if time is None: - return 'None' - return '%.3fs (%s or %s/%s)' % (time_base * time, time_base * time, time_base.numerator * time, time_base.denominator) - - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('path') -arg_parser.add_argument('-f', '--format') -arg_parser.add_argument('-a', '--audio', action='store_true') -arg_parser.add_argument('-v', '--video', action='store_true') -arg_parser.add_argument('-s', '--subs', action='store_true') -arg_parser.add_argument('-d', '--data', action='store_true') -arg_parser.add_argument('--dump-packets', action='store_true') -arg_parser.add_argument('--dump-planes', action='store_true') -arg_parser.add_argument('-p', '--play', action='store_true') -arg_parser.add_argument('-t', '--thread-type') -arg_parser.add_argument('-o', '--option', action='append', default=[]) -arg_parser.add_argument('-c', '--count', type=int, default=5) -args = arg_parser.parse_args() - - -proc = None - -options = dict(x.split('=') for x in args.option) -container = open(args.path, format=args.format, options=options) - -print('container:', container) -print('\tformat:', container.format) -print('\tduration:', float(container.duration) / time_base) -print('\tmetadata:') -for k, v in sorted(container.metadata.items()): - print('\t\t%s: %r' % (k, v)) -print() - -print(len(container.streams), 'stream(s):') -for i, stream in enumerate(container.streams): - - if args.thread_type: - stream.codec_context.thread_type = args.thread_type - - print('\t%r' % stream) - print('\t\ttime_base: %r' % stream.time_base) - print('\t\trate: %r' % stream.rate) - print('\t\tstart_time: %r' % stream.start_time) - print('\t\tduration: %s' % format_time(stream.duration, stream.time_base)) - print('\t\tbit_rate: %r' % stream.bit_rate) - print('\t\tbit_rate_tolerance: %r' % stream.bit_rate_tolerance) - - codec_context = stream.codec_context - if codec_context: - print('\t\tcodec_context:', codec_context) - print('\t\t\ttime_base:', codec_context.time_base) - - if stream.type == b'audio': - print('\t\taudio:') - print('\t\t\tformat:', stream.format) - print('\t\t\tchannels: %s' % stream.channels) - - elif stream.type == 'video': - print('\t\tvideo:') - print('\t\t\tformat:', stream.format) - print('\t\t\taverage_rate: %r' % stream.average_rate) - - print('\t\tmetadata:') - for k, v in sorted(stream.metadata.items()): - print('\t\t\t%s: %r' % (k, v)) - - print() - - -streams = [s for s in container.streams if - (s.type == 'audio' and args.audio) or - (s.type == 'video' and args.video) or - (s.type == 'subtitle' and args.subs) or - (s.type == 'data' and args.data) -] - - -frame_count = 0 - -for i, packet in enumerate(container.demux(streams)): - - print('%02d %r' % (i, packet)) - print('\ttime_base: %s' % packet.time_base) - print('\tduration: %s' % format_time(packet.duration, packet.stream.time_base)) - print('\tpts: %s' % format_time(packet.pts, packet.stream.time_base)) - print('\tdts: %s' % format_time(packet.dts, packet.stream.time_base)) - print('\tkey: %s' % packet.is_keyframe) - - if args.dump_packets: - print(bytes(packet)) - - for frame in packet.decode(): - - frame_count += 1 - - print('\tdecoded:', frame) - print('\t\ttime_base: %s' % frame.time_base) - print('\t\tpts:', format_time(frame.pts, packet.stream.time_base)) - - if packet.stream.type == 'video': - pass - - elif packet.stream.type == 'audio': - print('\t\tsamples:', frame.samples) - print('\t\tformat:', frame.format.name) - print('\t\tlayout:', frame.layout.name) - - elif packet.stream.type == 'subtitle': - - sub = frame - - print('\t\tformat:', sub.format) - print('\t\tstart_display_time:', format_time(sub.start_display_time, packet.stream.time_base)) - print('\t\tend_display_time:', format_time(sub.end_display_time, packet.stream.time_base)) - print('\t\trects: %d' % len(sub.rects)) - for rect in sub.rects: - print('\t\t\t%r' % rect) - if rect.type == 'ass': - print('\t\t\t\tass: %r' % rect.ass) - - if args.play and packet.stream.type == 'audio': - if not proc: - cmd = ['ffplay', - '-f', 's16le', - '-ar', str(packet.stream.time_base), - '-vn', '-', - ] - proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) - try: - proc.stdin.write(frame.planes[0].to_bytes()) - except IOError as e: - print(e) - exit() - - if args.dump_planes: - print('\t\tplanes') - for i, plane in enumerate(frame.planes or ()): - data = plane.to_bytes() - print('\t\t\tPLANE %d, %d bytes' % (i, len(data))) - data = data.encode('hex') - for i in xrange(0, len(data), 128): - print('\t\t\t%s' % data[i:i + 128]) - - if args.count and frame_count >= args.count: - exit() - - print() diff --git a/scratchpad/dump_format.py b/scratchpad/dump_format.py deleted file mode 100644 index 80682fb47..000000000 --- a/scratchpad/dump_format.py +++ /dev/null @@ -1,10 +0,0 @@ -import sys -import logging - -logging.basicConfig(level=logging.DEBUG) -logging.getLogger('libav').setLevel(logging.DEBUG) - -import av - -fh = av.open(sys.argv[1]) -print(fh.dumps_format()) diff --git a/scratchpad/encode.py b/scratchpad/encode.py deleted file mode 100644 index 5efa06ac6..000000000 --- a/scratchpad/encode.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import print_function -import argparse -import logging -import os -import sys - -import av -from tests.common import asset, sandboxed - - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('-v', '--verbose', action='store_true') -arg_parser.add_argument('input', nargs=1) -args = arg_parser.parse_args() - -input_file = av.open(args.input[0]) -input_video_stream = None # next((s for s in input_file.streams if s.type == 'video'), None) -input_audio_stream = next((s for s in input_file.streams if s.type == 'audio'), None) - -# open output file -output_file_path = sandboxed('encoded-' + os.path.basename(args.input[0])) -output_file = av.open(output_file_path, 'w') -output_video_stream = output_file.add_stream("mpeg4", 24) if input_video_stream else None -output_audio_stream = output_file.add_stream("mp3") if input_audio_stream else None - - -frame_count = 0 - - -for packet in input_file.demux([s for s in (input_video_stream, input_audio_stream) if s]): - - - if args.verbose: - print('in ', packet) - - for frame in packet.decode(): - - if args.verbose: - print('\t%s' % frame) - - if packet.stream.type == b'video': - if frame_count % 10 == 0: - if frame_count: - print() - print(('%03d:' % frame_count), end=' ') - sys.stdout.write('.') - sys.stdout.flush() - - frame_count += 1 - - # Signal to generate it's own timestamps. - frame.pts = None - - stream = output_audio_stream if packet.stream.type == b'audio' else output_video_stream - output_packets = [output_audio_stream.encode(frame)] - while output_packets[-1]: - output_packets.append(output_audio_stream.encode(None)) - - for p in output_packets: - if p: - if args.verbose: - print('OUT', p) - output_file.mux(p) - - if frame_count >= 100: - break - -print('-' * 78) - -# Finally we need to flush out the frames that are buffered in the encoder. -# To do that we simply call encode with no args until we get a None returned -if output_audio_stream: - while True: - output_packet = output_audio_stream.encode(None) - if output_packet: - if args.verbose: - print('<<<', output_packet) - output_file.mux(output_packet) - else: - break - -if output_video_stream: - while True: - output_packet = output_video_stream.encode(None) - if output_packet: - if args.verbose: - print('<<<', output_packet) - output_file.mux(output_packet) - else: - break - -output_file.close() diff --git a/scratchpad/encode_frames.py b/scratchpad/encode_frames.py deleted file mode 100644 index 19d1e28dc..000000000 --- a/scratchpad/encode_frames.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import print_function -import argparse -import os -import sys - -import av -import cv2 - - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('-r', '--rate', default='23.976') -arg_parser.add_argument('-f', '--format', default='yuv420p') -arg_parser.add_argument('-w', '--width', type=int) -arg_parser.add_argument('--height', type=int) -arg_parser.add_argument('-b', '--bitrate', type=int, default=8000000) -arg_parser.add_argument('-c', '--codec', default='mpeg4') -arg_parser.add_argument('inputs', nargs='+') -arg_parser.add_argument('output', nargs=1) -args = arg_parser.parse_args() - - -output = av.open(args.output[0], 'w') -stream = output.add_stream(args.codec, args.rate) -stream.bit_rate = args.bitrate -stream.pix_fmt = args.format - -for i, path in enumerate(args.inputs): - - print(os.path.basename(path)) - - img = cv2.imread(path) - - if not i: - stream.height = args.height or (args.width * img.shape[0] / img.shape[1]) or img.shape[0] - stream.width = args.width or img.shape[1] - - frame = av.VideoFrame.from_ndarray(img, format='bgr24') - packet = stream.encode(frame) - output.mux(packet) - -output.close() diff --git a/scratchpad/filter_audio.py b/scratchpad/filter_audio.py deleted file mode 100644 index 660996779..000000000 --- a/scratchpad/filter_audio.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Simple audio filtering example ported from C code: - https://github.com/FFmpeg/FFmpeg/blob/master/doc/examples/filter_audio.c -""" -from __future__ import division, print_function - -from fractions import Fraction -import hashlib -import sys - -import numpy as np - -import av -import av.audio.frame as af -import av.filter - - -FRAME_SIZE = 1024 - -INPUT_SAMPLE_RATE = 48000 -INPUT_FORMAT = 'fltp' -INPUT_CHANNEL_LAYOUT = '5.0(side)' # -> AV_CH_LAYOUT_5POINT0 - -OUTPUT_SAMPLE_RATE = 44100 -OUTPUT_FORMAT = 's16' # notice, packed audio format, expect only one plane in output -OUTPUT_CHANNEL_LAYOUT = 'stereo' # -> AV_CH_LAYOUT_STEREO - -VOLUME_VAL = 0.90 - - -def init_filter_graph(): - graph = av.filter.Graph() - - output_format = 'sample_fmts={}:sample_rates={}:channel_layouts={}'.format( - OUTPUT_FORMAT, - OUTPUT_SAMPLE_RATE, - OUTPUT_CHANNEL_LAYOUT - ) - print('Output format: {}'.format(output_format)) - - # initialize filters - filter_chain = [ - graph.add_abuffer(format=INPUT_FORMAT, - sample_rate=INPUT_SAMPLE_RATE, - layout=INPUT_CHANNEL_LAYOUT, - time_base=Fraction(1, INPUT_SAMPLE_RATE)), - # initialize filter with keyword parameters - graph.add('volume', volume=str(VOLUME_VAL)), - # or compound string configuration - graph.add('aformat', output_format), - graph.add('abuffersink') - ] - - # link up the filters into a chain - print('Filter graph:') - for c, n in zip(filter_chain, filter_chain[1:]): - print('\t{} -> {}'.format(c, n)) - c.link_to(n) - - # initialize the filter graph - graph.configure() - - return graph - - -def get_input(frame_num): - """ - Manually construct and update AudioFrame. - Consider using AudioFrame.from_ndarry for most real life numpy->AudioFrame conversions. - - :param frame_num: - :return: - """ - frame = av.AudioFrame(format=INPUT_FORMAT, layout=INPUT_CHANNEL_LAYOUT, samples=FRAME_SIZE) - frame.sample_rate = INPUT_SAMPLE_RATE - frame.pts = frame_num * FRAME_SIZE - - for i in range(len(frame.layout.channels)): - data = np.zeros(FRAME_SIZE, dtype=af.format_dtypes[INPUT_FORMAT]) - for j in range(FRAME_SIZE): - data[j] = np.sin(2 * np.pi * (frame_num + j) * (i + 1) / float(FRAME_SIZE)) - frame.planes[i].update(data) - - return frame - - -def process_output(frame): - data = frame.to_ndarray() - for i in range(data.shape[0]): - m = hashlib.md5(data[i, :].tobytes()) - print('Plane: {:0d} checksum: {}'.format(i, m.hexdigest())) - - -def main(duration): - frames_count = int(duration * INPUT_SAMPLE_RATE / FRAME_SIZE) - - graph = init_filter_graph() - - for f in range(frames_count): - frame = get_input(f) - - # submit the frame for processing - graph.push(frame) - - # pull frames from graph until graph has done processing or is waiting for a new input - while True: - try: - out_frame = graph.pull() - process_output(out_frame) - except (BlockingIOError, av.EOFError): - break - - # process any remaining buffered frames - while True: - try: - out_frame = graph.pull() - process_output(out_frame) - except (BlockingIOError, av.EOFError): - break - - -if __name__ == '__main__': - duration = 1.0 if len(sys.argv) < 2 else float(sys.argv[1]) - main(duration) diff --git a/scratchpad/frame_seek_example.py b/scratchpad/frame_seek_example.py deleted file mode 100644 index 021148990..000000000 --- a/scratchpad/frame_seek_example.py +++ /dev/null @@ -1,419 +0,0 @@ -from __future__ import print_function -""" -Note this example only really works accurately on constant frame rate media. -""" -from PyQt4 import QtGui -from PyQt4 import QtCore -from PyQt4.QtCore import Qt - -import sys -import av - - -AV_TIME_BASE = 1000000 - -def pts_to_frame(pts, time_base, frame_rate, start_time): - return int(pts * time_base * frame_rate) - int(start_time * time_base * frame_rate) - -def get_frame_rate(stream): - - if stream.average_rate.denominator and stream.average_rate.numerator: - return float(stream.average_rate) - if stream.time_base.denominator and stream.time_base.numerator: - return 1.0 / float(stream.time_base) - else: - raise ValueError("Unable to determine FPS") - -def get_frame_count(f, stream): - - if stream.frames: - return stream.frames - elif stream.duration: - return pts_to_frame(stream.duration, float(stream.time_base), get_frame_rate(stream), 0) - elif f.duration: - return pts_to_frame(f.duration, 1 / float(AV_TIME_BASE), get_frame_rate(stream), 0) - - else: - raise ValueError("Unable to determine number for frames") - -class FrameGrabber(QtCore.QObject): - - frame_ready = QtCore.pyqtSignal(object, object) - update_frame_range = QtCore.pyqtSignal(object) - - def __init__(self, parent=None): - super(FrameGrabber, self).__init__(parent) - self.file = None - self.stream = None - self.frame = None - self.active_frame = None - self.start_time = 0 - self.pts_seen = False - self.nb_frames = None - - self.rate = None - self.time_base = None - - def next_frame(self): - - frame_index = None - - rate = self.rate - time_base = self.time_base - - self.pts_seen = False - - for packet in self.file.demux(self.stream): - #print " pkt", packet.pts, packet.dts, packet - if packet.pts: - self.pts_seen = True - - for frame in packet.decode(): - - if frame_index is None: - - if self.pts_seen: - pts = frame.pts - else: - pts = frame.dts - - if not pts is None: - frame_index = pts_to_frame(pts, time_base, rate, self.start_time) - - elif not frame_index is None: - frame_index += 1 - - - yield frame_index, frame - - - @QtCore.pyqtSlot(object) - def request_frame(self, target_frame): - - frame = self.get_frame(target_frame) - if not frame: - return - - rgba = frame.reformat(frame.width, frame.height, "rgb24", 'itu709') - #print rgba.to_image().save("test.png") - # could use the buffer interface here instead, some versions of PyQt don't support it for some reason - # need to track down which version they added support for it - self.frame = bytearray(rgba.planes[0]) - bytesPerPixel = 3 - img = QtGui.QImage(self.frame, rgba.width, rgba.height, rgba.width * bytesPerPixel, QtGui.QImage.Format_RGB888) - - #img = QtGui.QImage(rgba.planes[0], rgba.width, rgba.height, QtGui.QImage.Format_RGB888) - - #pixmap = QtGui.QPixmap.fromImage(img) - self.frame_ready.emit(img, target_frame) - - def get_frame(self, target_frame): - - if target_frame != self.active_frame: - return - print('seeking to', target_frame) - - seek_frame = target_frame - - rate = self.rate - time_base = self.time_base - - frame = None - reseek = 250 - - original_target_frame_pts = None - - while reseek >= 0: - - # convert seek_frame to pts - target_sec = seek_frame * 1 / rate - target_pts = int(target_sec / time_base) + self.start_time - - if original_target_frame_pts is None: - original_target_frame_pts = target_pts - - self.stream.seek(int(target_pts)) - - frame_index = None - - frame_cache = [] - - for i, (frame_index, frame) in enumerate(self.next_frame()): - - # optimization if the time slider has changed, the requested frame no longer valid - if target_frame != self.active_frame: - return - - print(" ", i, "at frame", frame_index, "at ts:", frame.pts, frame.dts, "target:", target_pts, 'orig', original_target_frame_pts) - - if frame_index is None: - pass - - elif frame_index >= target_frame: - break - - frame_cache.append(frame) - - # Check if we over seeked, if we over seekd we need to seek to a earlier time - # but still looking for the target frame - if frame_index != target_frame: - - if frame_index is None: - over_seek = '?' - else: - over_seek = frame_index - target_frame - if frame_index > target_frame: - - print(over_seek, frame_cache) - if over_seek <= len(frame_cache): - print("over seeked by %i, using cache" % over_seek) - frame = frame_cache[-over_seek] - break - - - seek_frame -= 1 - reseek -= 1 - print("over seeked by %s, backtracking.. seeking: %i target: %i retry: %i" % (str(over_seek), seek_frame, target_frame, reseek)) - - else: - break - - if reseek < 0: - raise ValueError("seeking failed %i" % frame_index) - - # frame at this point should be the correct frame - - if frame: - - return frame - - else: - raise ValueError("seeking failed %i" % target_frame) - - def get_frame_count(self): - - frame_count = None - - if self.stream.frames: - frame_count = self.stream.frames - elif self.stream.duration: - frame_count = pts_to_frame(self.stream.duration, float(self.stream.time_base), get_frame_rate(self.stream), 0) - elif self.file.duration: - frame_count = pts_to_frame(self.file.duration, 1 / float(AV_TIME_BASE), get_frame_rate(self.stream), 0) - else: - raise ValueError("Unable to determine number for frames") - - seek_frame = frame_count - - retry = 100 - - while retry: - target_sec = seek_frame * 1 / self.rate - target_pts = int(target_sec / self.time_base) + self.start_time - - self.stream.seek(int(target_pts)) - - frame_index = None - - for frame_index, frame in self.next_frame(): - print(frame_index, frame) - continue - - if not frame_index is None: - break - else: - seek_frame -= 1 - retry -= 1 - - - print("frame count seeked", frame_index, "container frame count", frame_count) - - return frame_index or frame_count - - @QtCore.pyqtSlot(object) - def set_file(self, path): - self.file = av.open(path) - self.stream = next(s for s in self.file.streams if s.type == b'video') - self.rate = get_frame_rate(self.stream) - self.time_base = float(self.stream.time_base) - - - index, first_frame = next(self.next_frame()) - self.stream.seek(self.stream.start_time) - - # find the pts of the first frame - index, first_frame = next(self.next_frame()) - - if self.pts_seen: - pts = first_frame.pts - else: - pts = first_frame.dts - - self.start_time = pts or first_frame.dts - - print("First pts", pts, self.stream.start_time, first_frame) - - #self.nb_frames = get_frame_count(self.file, self.stream) - self.nb_frames = self.get_frame_count() - - self.update_frame_range.emit(self.nb_frames) - - - - - -class DisplayWidget(QtGui.QLabel): - def __init__(self, parent=None): - super(DisplayWidget, self).__init__(parent) - #self.setScaledContents(True) - self.setMinimumSize(1920 / 10, 1080 / 10) - - size_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) - size_policy.setHeightForWidth(True) - - self.setSizePolicy(size_policy) - - self.setAlignment(Qt.AlignHCenter | Qt.AlignBottom) - - self.pixmap = None - self.setMargin(10) - - def heightForWidth(self, width): - return width * 9 / 16.0 - - @QtCore.pyqtSlot(object, object) - def setPixmap(self, img, index): - #if index == self.current_index: - self.pixmap = QtGui.QPixmap.fromImage(img) - - #super(DisplayWidget, self).setPixmap(self.pixmap) - super(DisplayWidget, self).setPixmap(self.pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) - - def sizeHint(self): - width = self.width() - return QtCore.QSize(width, self.heightForWidth(width)) - - def resizeEvent(self, event): - if self.pixmap: - super(DisplayWidget, self).setPixmap(self.pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) - - def sizeHint(self): - return QtCore.QSize(1920 / 2.5, 1080 / 2.5) - - -class VideoPlayerWidget(QtGui.QWidget): - - request_frame = QtCore.pyqtSignal(object) - - load_file = QtCore.pyqtSignal(object) - - def __init__(self, parent=None): - super(VideoPlayerWidget, self).__init__(parent) - self.display = DisplayWidget() - self.timeline = QtGui.QScrollBar(Qt.Horizontal) - self.frame_grabber = FrameGrabber() - - self.frame_control = QtGui.QSpinBox() - self.frame_control.setFixedWidth(100) - - self.timeline.valueChanged.connect(self.frame_changed) - self.frame_control.valueChanged.connect(self.frame_changed) - - self.request_frame.connect(self.frame_grabber.request_frame) - self.load_file.connect(self.frame_grabber.set_file) - - self.frame_grabber.frame_ready.connect(self.display.setPixmap) - self.frame_grabber.update_frame_range.connect(self.set_frame_range) - - self.frame_grabber_thread = QtCore.QThread() - - self.frame_grabber.moveToThread(self.frame_grabber_thread) - self.frame_grabber_thread.start() - - control_layout = QtGui.QHBoxLayout() - control_layout.addWidget(self.frame_control) - control_layout.addWidget(self.timeline) - - layout = QtGui.QVBoxLayout() - layout.addWidget(self.display) - layout.addLayout(control_layout) - self.setLayout(layout) - self.setAcceptDrops(True) - - def set_file(self, path): - #self.frame_grabber.set_file(path) - self.load_file.emit(path) - self.frame_changed(0) - - @QtCore.pyqtSlot(object) - def set_frame_range(self, maximum): - print("frame range =", maximum) - self.timeline.setMaximum(maximum) - self.frame_control.setMaximum(maximum) - - def frame_changed(self, value): - self.timeline.blockSignals(True) - self.frame_control.blockSignals(True) - - self.timeline.setValue(value) - self.frame_control.setValue(value) - - self.timeline.blockSignals(False) - self.frame_control.blockSignals(False) - - #self.display.current_index = value - self.frame_grabber.active_frame = value - - self.request_frame.emit(value) - - def keyPressEvent(self, event): - if event.key() in (Qt.Key_Right, Qt.Key_Left): - direction = 1 - if event.key() == Qt.Key_Left: - direction = -1 - - if event.modifiers() == Qt.ShiftModifier: - print('shift') - direction *= 10 - - self.timeline.setValue(self.timeline.value() + direction) - - else: - super(VideoPlayerWidget, self).keyPressEvent(event) - - def mousePressEvent(self, event): - # clear focus of spinbox - focused_widget = QtGui.QApplication.focusWidget() - if focused_widget: - focused_widget.clearFocus() - - super(VideoPlayerWidget, self).mousePressEvent(event) - - def dragEnterEvent(self, event): - event.accept() - - def dropEvent(self, event): - - mime = event.mimeData() - event.accept() - - - if mime.hasUrls(): - path = str(mime.urls()[0].path()) - self.set_file(path) - def closeEvent(self, event): - - self.frame_grabber.active_frame = -1 - self.frame_grabber_thread.quit() - self.frame_grabber_thread.wait() - - event.accept() - - -if __name__ == "__main__": - app = QtGui.QApplication(sys.argv) - window = VideoPlayerWidget() - test_file = sys.argv[1] - window.set_file(test_file) - window.show() - sys.exit(app.exec_()) diff --git a/scratchpad/glproxy.py b/scratchpad/glproxy.py deleted file mode 100644 index b6054e648..000000000 --- a/scratchpad/glproxy.py +++ /dev/null @@ -1,97 +0,0 @@ -'''Mikes wrapper for the visualizer???''' -from contextlib import contextmanager - -from OpenGL.GLUT import * -from OpenGL.GLU import * -from OpenGL.GL import * -import OpenGL - - -__all__ = ''' - gl - glu - glut -'''.strip().split() - - -class ModuleProxy(object): - - def __init__(self, name, module): - self.name = name - self.module = module - - def __getattr__(self, name): - if name.isupper(): - return getattr(self.module, self.name.upper() + '_' + name) - else: - # convert to camel case - name = name.split('_') - name = [x[0].upper() + x[1:] for x in name] - name = ''.join(name) - return getattr(self.module, self.name + name) - - -class GLProxy(ModuleProxy): - - @contextmanager - def matrix(self): - self.module.glPushMatrix() - try: - yield - finally: - self.module.glPopMatrix() - - @contextmanager - def attrib(self, *args): - mask = 0 - for arg in args: - if isinstance(arg, str): - arg = getattr(self.module, 'GL_%s_BIT' % arg.upper()) - mask |= arg - self.module.glPushAttrib(mask) - try: - yield - finally: - self.module.glPopAttrib() - - def enable(self, *args, **kwargs): - self._enable(True, args, kwargs) - return self._apply_on_exit(self._enable, False, args, kwargs) - - def disable(self, *args, **kwargs): - self._enable(False, args, kwargs) - return self._apply_on_exit(self._enable, True, args, kwargs) - - def _enable(self, enable, args, kwargs): - todo = [] - for arg in args: - if isinstance(arg, str): - arg = getattr(self.module, 'GL_%s' % arg.upper()) - todo.append((arg, enable)) - for key, value in kwargs.iteritems(): - flag = getattr(self.module, 'GL_%s' % key.upper()) - value = value if enable else not value - todo.append((flag, value)) - for flag, value in todo: - if value: - self.module.glEnable(flag) - else: - self.module.glDisable(flag) - - def begin(self, arg): - if isinstance(arg, str): - arg = getattr(self.module, 'GL_%s' % arg.upper()) - self.module.glBegin(arg) - return self._apply_on_exit(self.module.glEnd) - - @contextmanager - def _apply_on_exit(self, func, *args, **kwargs): - try: - yield - finally: - func(*args, **kwargs) - - -gl = GLProxy('gl', OpenGL.GL) -glu = ModuleProxy('glu', OpenGL.GLU) -glut = ModuleProxy('glut', OpenGL.GLUT) diff --git a/scratchpad/graph.py b/scratchpad/graph.py deleted file mode 100644 index fc45b686c..000000000 --- a/scratchpad/graph.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import print_function -from av.filter.graph import Graph - -g = Graph() -print(g.dump()) - -f = g.pull() - -print(f) - -f = f.reformat(format='rgb24') - -print(f) - -img = f.to_image() - -print(img) - -img.save('graph.png') diff --git a/scratchpad/player.py b/scratchpad/player.py deleted file mode 100644 index c6ad3e51a..000000000 --- a/scratchpad/player.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import print_function -import argparse -import ctypes -import os -import sys -import pprint -import time - -from qtproxy import Q -from glproxy import gl - -import av - -WIDTH = 960 -HEIGHT = 540 - - -class PlayerGLWidget(Q.GLWidget): - - def initializeGL(self): - print('initialize GL') - gl.clearColor(0, 0, 0, 0) - - gl.enable(gl.TEXTURE_2D) - - # gl.texEnv(gl.TEXTURE_ENV, gl.TEXTURE_ENV_MODE, gl.DECAL) - self.tex_id = gl.genTextures(1) - gl.bindTexture(gl.TEXTURE_2D, self.tex_id) - gl.texParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) - gl.texParameter(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) - - print('texture id', self.tex_id) - - def setImage(self, w, h, img): - gl.texImage2D(gl.TEXTURE_2D, 0, 3, w, h, 0, gl.RGB, gl.UNSIGNED_BYTE, img) - - def resizeGL(self, w, h): - print('resize to', w, h) - gl.viewport(0, 0, w, h) - # gl.matrixMode(gl.PROJECTION) - # gl.loadIdentity() - # gl.ortho(0, w, 0, h, -10, 10) - # gl.matrixMode(gl.MODELVIEW) - - def paintGL(self): - # print 'paint!' - gl.clear(gl.COLOR_BUFFER_BIT) - with gl.begin('polygon'): - gl.texCoord(0, 0); gl.vertex(-1, 1) - gl.texCoord(1, 0); gl.vertex(1, 1) - gl.texCoord(1, 1); gl.vertex(1, -1) - gl.texCoord(0, 1); gl.vertex(-1, -1) - - - -parser = argparse.ArgumentParser() -parser.add_argument('-f', '--format') -parser.add_argument('path') -args = parser.parse_args() - - -def _iter_images(): - video = av.open(args.path, format=args.format) - stream = next(s for s in video.streams if s.type == b'video') - for packet in video.demux(stream): - for frame in packet.decode(): - yield frame.reformat(frame.width, frame.height, 'rgb24') - -image_iter = _iter_images() - -app = Q.Application([]) - -glwidget = PlayerGLWidget() -glwidget.setFixedWidth(WIDTH) -glwidget.setFixedHeight(HEIGHT) -glwidget.show() -glwidget.raise_() - -start_time = 0 -count = 0 - -timer = Q.Timer() -timer.setInterval(1000 / 30) -@timer.timeout.connect -def on_timeout(*args): - - global start_time, count - start_time = start_time or time.time() - - frame = next(image_iter) - ptr = ctypes.c_void_p(frame.planes[0].ptr) - glwidget.setImage(frame.width, frame.height, ptr) - glwidget.updateGL() - - count += 1 - elapsed = time.time() - start_time - print(frame.pts, frame.dts, '%.2ffps' % (count / elapsed)) - -timer.start() - -app.exec_() diff --git a/scratchpad/qtproxy.py b/scratchpad/qtproxy.py deleted file mode 100644 index a39b76fa2..000000000 --- a/scratchpad/qtproxy.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys - -sys.path.append('/usr/local/lib/python2.7/site-packages') -from PyQt4 import QtCore, QtGui, QtOpenGL, QtMultimedia - - -class QtProxy(object): - - def __init__(self, *modules): - self._modules = modules - - def __getattr__(self, base_name): - for mod in self._modules: - for prefix in ('Q', '', 'Qt'): - name = prefix + base_name - obj = getattr(mod, name, None) - if obj is not None: - setattr(self, base_name, obj) - return obj - raise AttributeError(base_name) - -Q = QtProxy(QtGui, QtCore, QtCore.Qt, QtOpenGL, QtMultimedia) diff --git a/scratchpad/remux.py b/scratchpad/remux.py deleted file mode 100644 index 3db1b264c..000000000 --- a/scratchpad/remux.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import print_function -import array -import argparse -import logging -import sys -import pprint -import subprocess - -from PIL import Image - -from av import open, time_base - - -logging.basicConfig(level=logging.DEBUG) - - -def format_time(time, time_base): - if time is None: - return 'None' - return '%.3fs (%s or %s/%s)' % (time_base * time, time_base * time, time_base.numerator * time, time_base.denominator) - - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('input') -arg_parser.add_argument('output') -arg_parser.add_argument('-F', '--iformat') -arg_parser.add_argument('-O', '--ioption', action='append', default=[]) -arg_parser.add_argument('-f', '--oformat') -arg_parser.add_argument('-o', '--ooption', action='append', default=[]) -arg_parser.add_argument('-a', '--noaudio', action='store_true') -arg_parser.add_argument('-v', '--novideo', action='store_true') -arg_parser.add_argument('-s', '--nosubs', action='store_true') -arg_parser.add_argument('-d', '--nodata', action='store_true') -arg_parser.add_argument('-c', '--count', type=int, default=0) -args = arg_parser.parse_args() - - -input_ = open(args.input, - format=args.iformat, - options=dict(x.split('=') for x in args.ioption), -) -output = open(args.output, 'w', - format=args.oformat, - options=dict(x.split('=') for x in args.ooption), -) - -in_to_out = {} - -for i, stream in enumerate(input_.streams): - - if ( - (stream.type == b'audio' and not args.noaudio) or - (stream.type == b'video' and not args.novideo) or - (stream.type == b'subtitle' and not args.nosubtitle) or - (stream.type == b'data' and not args.nodata) - ): - in_to_out[stream] = ostream = output.add_stream(template=stream) - -for i, packet in enumerate(input_.demux(in_to_out.keys())): - - if args.count and i >= args.count: - break - print('%02d %r' % (i, packet)) - print('\tin: ', packet.stream) - - if packet.dts is None: - continue - - packet.stream = in_to_out[packet.stream] - - print('\tout:', packet.stream) - - output.mux(packet) - - -output.close() diff --git a/scratchpad/resource_use.py b/scratchpad/resource_use.py deleted file mode 100644 index 4b2791aa9..000000000 --- a/scratchpad/resource_use.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import division, print_function -from __future__ import division - -import argparse -import resource -import gc - -import av - - -parser = argparse.ArgumentParser() -parser.add_argument('-c', '--count', type=int, default=5) -parser.add_argument('-f', '--frames', type=int, default=100) -parser.add_argument('--print', dest='print_', action='store_true') -parser.add_argument('--to-rgb', action='store_true') -parser.add_argument('--to-image', action='store_true') -parser.add_argument('--gc', '-g', action='store_true') -parser.add_argument('input') -args = parser.parse_args() - -def format_bytes(n): - order = 0 - while n > 1024: - order += 1 - n //= 1024 - return '%d%sB' % (n, ('', 'k', 'M', 'G', 'T', 'P')[order]) - -usage = [] - -for round_ in xrange(args.count): - - print('Round %d/%d:' % (round_ + 1, args.count)) - - if args.gc: - gc.collect() - - usage.append(resource.getrusage(resource.RUSAGE_SELF)) - - fh = av.open(args.input) - vs = next(s for s in fh.streams if s.type == 'video') - - fi = 0 - for packet in fh.demux([vs]): - for frame in packet.decode(): - if args.print_: - print(frame) - if args.to_rgb: - print(frame.to_rgb()) - if args.to_image: - print(frame.to_image()) - fi += 1 - if fi > args.frames: - break - - frame = packet = fh = vs = None - - - -usage.append(resource.getrusage(resource.RUSAGE_SELF)) - -for i in xrange(len(usage) - 1): - before = usage[i] - after = usage[i + 1] - print('%s (%s)' % (format_bytes(after.ru_maxrss), format_bytes(after.ru_maxrss - before.ru_maxrss))) diff --git a/scratchpad/save_subtitles.py b/scratchpad/save_subtitles.py deleted file mode 100644 index 97f8f415f..000000000 --- a/scratchpad/save_subtitles.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import print_function -""" - -As you can see, the subtitle API needs some work. - -""" - -import os -import sys -import pprint - -from PIL import Image - -from av import open - - -if not os.path.exists('subtitles'): - os.makedirs('subtitles') - - -video = open(sys.argv[1]) - -streams = [s for s in video.streams if s.type == b'subtitle'] -if not streams: - print('no subtitles') - exit(1) - -print(streams) - -count = 0 -for pi, packet in enumerate(video.demux([streams[0]])): - - print('packet', pi) - for si, subtitle in enumerate(packet.decode()): - - print('\tsubtitle', si, subtitle) - - for ri, rect in enumerate(subtitle.rects): - if rect.type == 'ass': - print('\t\tass: ', rect, rect.ass.rstrip('\n')) - if rect.type == 'text': - print('\t\ttext: ', rect, rect.text.rstrip('\n')) - if rect.type == 'bitmap': - print('\t\tbitmap: ', rect, rect.width, rect.height, rect.pict_buffers) - buffers = [b for b in rect.pict_buffers if b is not None] - if buffers: - imgs = [ - Image.frombuffer('L', (rect.width, rect.height), buffer, "raw", "L", 0, 1) - for buffer in buffers - ] - if len(imgs) == 1: - img = imgs[0] - elif len(imgs) == 2: - img = Image.merge('LA', imgs) - else: - img = Image.merge('RGBA', imgs) - img.save('subtitles/%04d.png' % count) - - count += 1 - if count > 10: - pass - # exit() diff --git a/scratchpad/second_seek_example.py b/scratchpad/second_seek_example.py deleted file mode 100644 index a9dc11698..000000000 --- a/scratchpad/second_seek_example.py +++ /dev/null @@ -1,500 +0,0 @@ -from __future__ import print_function -""" -Note this example only really works accurately on constant frame rate media. -""" -from PyQt4 import QtGui -from PyQt4 import QtCore -from PyQt4.QtCore import Qt - -import sys -import av - - -AV_TIME_BASE = 1000000 - -def pts_to_frame(pts, time_base, frame_rate, start_time): - return int(pts * time_base * frame_rate) - int(start_time * time_base * frame_rate) - -def get_frame_rate(stream): - - if stream.average_rate.denominator and stream.average_rate.numerator: - return float(stream.average_rate) - if stream.time_base.denominator and stream.time_base.numerator: - return 1.0 / float(stream.time_base) - else: - raise ValueError("Unable to determine FPS") - -def get_frame_count(f, stream): - - if stream.frames: - return stream.frames - elif stream.duration: - return pts_to_frame(stream.duration, float(stream.time_base), get_frame_rate(stream), 0) - elif f.duration: - return pts_to_frame(f.duration, 1 / float(AV_TIME_BASE), get_frame_rate(stream), 0) - - else: - raise ValueError("Unable to determine number for frames") - -class FrameGrabber(QtCore.QObject): - - frame_ready = QtCore.pyqtSignal(object, object) - update_frame_range = QtCore.pyqtSignal(object, object) - - def __init__(self, parent=None): - super(FrameGrabber, self).__init__(parent) - self.file = None - self.stream = None - self.frame = None - self.active_time = None - self.start_time = 0 - self.pts_seen = False - self.nb_frames = None - - self.rate = None - self.time_base = None - - self.pts_map = {} - - def next_frame(self): - - frame_index = None - - rate = self.rate - time_base = self.time_base - - self.pts_seen = False - - for packet in self.file.demux(self.stream): - #print " pkt", packet.pts, packet.dts, packet - if packet.pts: - self.pts_seen = True - - for frame in packet.decode(): - - if frame_index is None: - - if self.pts_seen: - pts = frame.pts - else: - pts = frame.dts - - if not pts is None: - frame_index = pts_to_frame(pts, time_base, rate, self.start_time) - - elif not frame_index is None: - frame_index += 1 - - if not frame.dts in self.pts_map: - secs = None - - if not pts is None: - secs = pts * time_base - - self.pts_map[frame.dts] = secs - - - #if frame.pts == None: - - - - yield frame_index, frame - - - - @QtCore.pyqtSlot(object) - def request_time(self, second): - - frame = self.get_frame(second) - if not frame: - return - - rgba = frame.reformat(frame.width, frame.height, "rgb24", 'itu709') - #print rgba.to_image().save("test.png") - # could use the buffer interface here instead, some versions of PyQt don't support it for some reason - # need to track down which version they added support for it - self.frame = bytearray(rgba.planes[0]) - bytesPerPixel = 3 - img = QtGui.QImage(self.frame, rgba.width, rgba.height, rgba.width * bytesPerPixel, QtGui.QImage.Format_RGB888) - - #img = QtGui.QImage(rgba.planes[0], rgba.width, rgba.height, QtGui.QImage.Format_RGB888) - - #pixmap = QtGui.QPixmap.fromImage(img) - self.frame_ready.emit(img, second) - - def get_frame(self, target_sec): - - if target_sec != self.active_time: - return - print('seeking to', target_sec) - - rate = self.rate - time_base = self.time_base - - target_pts = int(target_sec / time_base) + self.start_time - seek_pts = target_pts - - - self.stream.seek(seek_pts) - - #frame_cache = [] - - last_frame = None - - for i, (frame_index, frame) in enumerate(self.next_frame()): - - - if target_sec != self.active_time: - return - - pts = frame.dts - if self.pts_seen: - pts = frame.pts - - if pts > target_pts: - break - - print(frame.pts, seek_pts) - last_frame = frame - - if last_frame: - - return last_frame - - - def get_frame_old(self, target_frame): - - if target_frame != self.active_frame: - return - print('seeking to', target_frame) - - seek_frame = target_frame - - rate = self.rate - time_base = self.time_base - - frame = None - reseek = 250 - - original_target_frame_pts = None - - while reseek >= 0: - - # convert seek_frame to pts - target_sec = seek_frame * 1 / rate - target_pts = int(target_sec / time_base) + self.start_time - - if original_target_frame_pts is None: - original_target_frame_pts = target_pts - - self.stream.seek(int(target_pts)) - - frame_index = None - - frame_cache = [] - - for i, (frame_index, frame) in enumerate(self.next_frame()): - - # optimization if the time slider has changed, the requested frame no longer valid - if target_frame != self.active_frame: - return - - print(" ", i, "at frame", frame_index, "at ts:", frame.pts, frame.dts, "target:", target_pts, 'orig', original_target_frame_pts) - - if frame_index is None: - pass - - elif frame_index >= target_frame: - break - - frame_cache.append(frame) - - # Check if we over seeked, if we over seekd we need to seek to a earlier time - # but still looking for the target frame - if frame_index != target_frame: - - if frame_index is None: - over_seek = '?' - else: - over_seek = frame_index - target_frame - if frame_index > target_frame: - - print(over_seek, frame_cache) - if over_seek <= len(frame_cache): - print("over seeked by %i, using cache" % over_seek) - frame = frame_cache[-over_seek] - break - - - seek_frame -= 1 - reseek -= 1 - print("over seeked by %s, backtracking.. seeking: %i target: %i retry: %i" % (str(over_seek), seek_frame, target_frame, reseek)) - - else: - break - - if reseek < 0: - raise ValueError("seeking failed %i" % frame_index) - - # frame at this point should be the correct frame - - if frame: - - return frame - - else: - raise ValueError("seeking failed %i" % target_frame) - - def get_frame_count(self): - - frame_count = None - - if self.stream.frames: - frame_count = self.stream.frames - elif self.stream.duration: - frame_count = pts_to_frame(self.stream.duration, float(self.stream.time_base), get_frame_rate(self.stream), 0) - elif self.file.duration: - frame_count = pts_to_frame(self.file.duration, 1 / float(AV_TIME_BASE), get_frame_rate(self.stream), 0) - else: - raise ValueError("Unable to determine number for frames") - - seek_frame = frame_count - - retry = 100 - - while retry: - target_sec = seek_frame * 1 / self.rate - target_pts = int(target_sec / self.time_base) + self.start_time - - self.stream.seek(int(target_pts)) - - frame_index = None - - for frame_index, frame in self.next_frame(): - print(frame_index, frame) - continue - - if not frame_index is None: - break - else: - seek_frame -= 1 - retry -= 1 - - - print("frame count seeked", frame_index, "container frame count", frame_count) - - return frame_index or frame_count - - @QtCore.pyqtSlot(object) - def set_file(self, path): - self.file = av.open(path) - self.stream = next(s for s in self.file.streams if s.type == b'video') - self.rate = get_frame_rate(self.stream) - self.time_base = float(self.stream.time_base) - - - index, first_frame = next(self.next_frame()) - self.stream.seek(self.stream.start_time) - - # find the pts of the first frame - index, first_frame = next(self.next_frame()) - - if self.pts_seen: - pts = first_frame.pts - else: - pts = first_frame.dts - - self.start_time = pts or first_frame.dts - - print("First pts", pts, self.stream.start_time, first_frame) - - #self.nb_frames = get_frame_count(self.file, self.stream) - self.nb_frames = self.get_frame_count() - - dur = None - - if self.stream.duration: - dur = self.stream.duration * self.time_base - else: - dur = self.file.duration * 1.0 / float(AV_TIME_BASE) - - self.update_frame_range.emit(dur, self.rate) - - - - - -class DisplayWidget(QtGui.QLabel): - def __init__(self, parent=None): - super(DisplayWidget, self).__init__(parent) - #self.setScaledContents(True) - self.setMinimumSize(1920 / 10, 1080 / 10) - - size_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) - size_policy.setHeightForWidth(True) - - self.setSizePolicy(size_policy) - - self.setAlignment(Qt.AlignHCenter | Qt.AlignBottom) - - self.pixmap = None - self.setMargin(10) - - def heightForWidth(self, width): - return width * 9 / 16.0 - - @QtCore.pyqtSlot(object, object) - def setPixmap(self, img, index): - #if index == self.current_index: - self.pixmap = QtGui.QPixmap.fromImage(img) - - #super(DisplayWidget, self).setPixmap(self.pixmap) - super(DisplayWidget, self).setPixmap(self.pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) - - def sizeHint(self): - width = self.width() - return QtCore.QSize(width, self.heightForWidth(width)) - - def resizeEvent(self, event): - if self.pixmap: - super(DisplayWidget, self).setPixmap(self.pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) - - def sizeHint(self): - return QtCore.QSize(1920 / 2.5, 1080 / 2.5) - - -class VideoPlayerWidget(QtGui.QWidget): - - request_time = QtCore.pyqtSignal(object) - - load_file = QtCore.pyqtSignal(object) - - def __init__(self, parent=None): - super(VideoPlayerWidget, self).__init__(parent) - - self.rate = None - - self.display = DisplayWidget() - self.timeline = QtGui.QScrollBar(Qt.Horizontal) - self.timeline_base = 100000 - - self.frame_grabber = FrameGrabber() - - self.frame_control = QtGui.QDoubleSpinBox() - self.frame_control.setFixedWidth(100) - - self.timeline.valueChanged.connect(self.slider_changed) - self.frame_control.valueChanged.connect(self.frame_changed) - - self.request_time.connect(self.frame_grabber.request_time) - self.load_file.connect(self.frame_grabber.set_file) - - self.frame_grabber.frame_ready.connect(self.display.setPixmap) - self.frame_grabber.update_frame_range.connect(self.set_frame_range) - - self.frame_grabber_thread = QtCore.QThread() - - self.frame_grabber.moveToThread(self.frame_grabber_thread) - self.frame_grabber_thread.start() - - control_layout = QtGui.QHBoxLayout() - control_layout.addWidget(self.frame_control) - control_layout.addWidget(self.timeline) - - layout = QtGui.QVBoxLayout() - layout.addWidget(self.display) - layout.addLayout(control_layout) - self.setLayout(layout) - self.setAcceptDrops(True) - - def set_file(self, path): - #self.frame_grabber.set_file(path) - self.load_file.emit(path) - self.frame_changed(0) - - @QtCore.pyqtSlot(object, object) - def set_frame_range(self, maximum, rate): - print("frame range =", maximum, rate, int(maximum * self.timeline_base)) - - self.timeline.setMaximum(int(maximum * self.timeline_base)) - - self.frame_control.setMaximum(maximum) - self.frame_control.setSingleStep(1 / rate) - #self.timeline.setSingleStep( int(AV_TIME_BASE * 1/rate)) - self.rate = rate - - def slider_changed(self, value): - print('..', value) - self.frame_changed(value * 1.0 / float(self.timeline_base)) - - def frame_changed(self, value): - self.timeline.blockSignals(True) - self.frame_control.blockSignals(True) - - self.timeline.setValue(int(value * self.timeline_base)) - self.frame_control.setValue(value) - - self.timeline.blockSignals(False) - self.frame_control.blockSignals(False) - - #self.display.current_index = value - self.frame_grabber.active_time = value - - self.request_time.emit(value) - - def keyPressEvent(self, event): - if event.key() in (Qt.Key_Right, Qt.Key_Left): - direction = 1 - if event.key() == Qt.Key_Left: - direction = -1 - - if event.modifiers() == Qt.ShiftModifier: - print('shift') - direction *= 10 - - direction = direction * 1 / self.rate - - self.frame_changed(self.frame_control.value() + direction) - - else: - super(VideoPlayerWidget, self).keyPressEvent(event) - - def mousePressEvent(self, event): - # clear focus of spinbox - focused_widget = QtGui.QApplication.focusWidget() - if focused_widget: - focused_widget.clearFocus() - - super(VideoPlayerWidget, self).mousePressEvent(event) - - def dragEnterEvent(self, event): - event.accept() - - def dropEvent(self, event): - - mime = event.mimeData() - event.accept() - - - if mime.hasUrls(): - path = str(mime.urls()[0].path()) - self.set_file(path) - def closeEvent(self, event): - - self.frame_grabber.active_time = -1 - self.frame_grabber_thread.quit() - self.frame_grabber_thread.wait() - - for key, value in sorted(self.frame_grabber.pts_map.items()): - print(key, '=', value) - - event.accept() - - -if __name__ == "__main__": - app = QtGui.QApplication(sys.argv) - window = VideoPlayerWidget() - test_file = sys.argv[1] - window.set_file(test_file) - window.show() - sys.exit(app.exec_()) diff --git a/scratchpad/seekmany.py b/scratchpad/seekmany.py deleted file mode 100644 index c1ca758e6..000000000 --- a/scratchpad/seekmany.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import print_function -import sys - -import av - -container = av.open(sys.argv[1]) -duration = container.duration -stream = container.streams.video[0] - -print('container.duration', duration, float(duration) / av.time_base) -print('container.time_base', av.time_base) -print('stream.duration', stream.duration) -print('stream.time_base', stream.time_base) -print('codec.time_base', stream.codec_context.time_base) -print('scale', float(stream.codec_context.time_base / stream.time_base)) -print() - -exit() - -real_duration = float(duration) / av.time_base -steps = 120 -tolerance = real_duration / (steps * 4) -print('real_duration', real_duration) -print() - -def iter_frames(): - for packet in container.demux(stream): - for frame in packet.decode(): - yield frame - -for i in xrange(steps): - - time = real_duration * i / steps - min_time = time - tolerance - - pts = time / stream.time_base - - print('seeking', time, pts) - stream.seek(int(pts)) - - skipped = 0 - for frame in iter_frames(): - ftime = float(frame.pts * stream.time_base) - if ftime >= min_time: - break - skipped += 1 - else: - print(' WARNING: iterated to the end') - - print(' ', skipped, frame.pts, float(frame.pts * stream.time_base)) # WTF is this stream.time_base? diff --git a/scratchpad/show_frames_opencv.py b/scratchpad/show_frames_opencv.py deleted file mode 100644 index c618afaac..000000000 --- a/scratchpad/show_frames_opencv.py +++ /dev/null @@ -1,21 +0,0 @@ -import os -import sys - -import cv2 -from av import open - - -video = open(sys.argv[1]) - -stream = next(s for s in video.streams if s.type == 'video') - -for packet in video.demux(stream): - for frame in packet.decode(): - # some other formats gray16be, bgr24, rgb24 - img = frame.to_ndarray(format='bgr24') - cv2.imshow("Test", img) - - if cv2.waitKey(1) == 27: - break - -cv2.destroyAllWindows() diff --git a/scratchpad/sidedata.py b/scratchpad/sidedata.py deleted file mode 100644 index a1157d7dd..000000000 --- a/scratchpad/sidedata.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys - -import av - - -fh = av.open(sys.argv[1]) -fh.streams.video[0].export_mvs = True -# fh.streams.video[0].flags2 |= 'EXPORT_MVS' - -for pi, packet in enumerate(fh.demux()): - for fi, frame in enumerate(packet.decode()): - - for di, data in enumerate(frame.side_data): - - print(pi, fi, di, data) - - print(data.to_ndarray()) - - for mi, vec in enumerate(data): - - print(mi, vec) - - if mi > 10: - exit() - diff --git a/scripts/activate.sh b/scripts/activate.sh index 1fdd6ee93..78eb08620 100755 --- a/scripts/activate.sh +++ b/scripts/activate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#! /usr/bin/env bash # Make sure this is sourced. if [[ "$0" == "${BASH_SOURCE[0]}" ]]; then @@ -6,25 +6,31 @@ if [[ "$0" == "${BASH_SOURCE[0]}" ]]; then exit 1 fi -export PYAV_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.."; pwd)" - -if [[ "$TRAVIS" ]]; then - PYAV_LIBRARY=$LIBRARY +if [[ -n "$ZSH_VERSION" ]]; then + export PYAV_ROOT="$(realpath -- "$(dirname -- "$(readlink -f -- "$0")")/..")" +else + export PYAV_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.."; pwd)" fi if [[ ! "$PYAV_LIBRARY" ]]; then - - # Pull from command line argument. if [[ "$1" ]]; then - PYAV_LIBRARY="$1" + if [[ "$1" == ffmpeg-* ]]; then + PYAV_LIBRARY="$1" + else + echo "Error: PYAV_LIBRARY must start with 'ffmpeg-'" >&2 + return 1 + fi else - PYAV_LIBRARY=ffmpeg-4.2 + PYAV_LIBRARY=ffmpeg-8.1.2 echo "No \$PYAV_LIBRARY set; defaulting to $PYAV_LIBRARY" fi fi export PYAV_LIBRARY -if [[ ! "$PYAV_PYTHON" ]]; then +if [[ "$VIRTUAL_ENV" ]]; then + PYAV_PYTHON="${VIRTUAL_ENV}/bin/python3" + echo "Using activated venv: $VIRTUAL_ENV" +elif [[ ! "$PYAV_PYTHON" ]]; then PYAV_PYTHON="${PYAV_PYTHON-python3}" echo 'No $PYAV_PYTHON set; defaulting to python3.' fi @@ -32,7 +38,6 @@ fi # Hack for PyPy on GitHub Actions. # This is because PYAV_PYTHON is constructed from "python${{ matrix.config.python }}" # resulting in "pythonpypy3", which won't work. -# It would be nice to clean this up, but I want it to work ASAP. if [[ "$PYAV_PYTHON" == *pypy* ]]; then PYAV_PYTHON=python fi @@ -40,16 +45,14 @@ fi export PYAV_PYTHON export PYAV_PIP="${PYAV_PIP-$PYAV_PYTHON -m pip}" -if [[ "$GITHUB_ACTION" || "$TRAVIS" ]]; then +if [[ "$GITHUB_ACTION" ]]; then - # GitHub/Travis as a very self-contained environment. Lets just work in that. + # GitHub has a very self-contained environment. Lets just work in that. echo "We're on CI, so not setting up another virtualenv." - if [[ "$TRAVIS_PYTHON_VERSION" = "2.7" || "$TRAVIS_PYTHON_VERSION" = "pypy" ]]; then - PYAV_PYTHON=python - PYAV_PIP=pip - fi - +elif [[ "$VIRTUAL_ENV" ]]; then + # Using activated venv + true else export PYAV_VENV_NAME="$(uname -s).$(uname -r).$("$PYAV_PYTHON" -c ' @@ -74,7 +77,6 @@ print("{}{}.{}".format(platform.python_implementation().lower(), *sys.version_in fi - # Just a flag so that we know this was supposedly run. export _PYAV_ACTIVATED=1 diff --git a/scripts/auditwheel-cross.py b/scripts/auditwheel-cross.py new file mode 100644 index 000000000..13c529dc7 --- /dev/null +++ b/scripts/auditwheel-cross.py @@ -0,0 +1,28 @@ +"""Run ``auditwheel`` for a foreign architecture/libc from an x86_64 host. + +auditwheel builds its ``--plat`` choices from the host's detected architecture +and libc, so on an x86_64 runner it rejects e.g. ``manylinux_2_31_armv7l``. The +repair logic itself re-derives the architecture and libc from the wheel and works +cross-arch, so we only need to override the host detection used to build the CLI +choice list. + +Usage:: + + python auditwheel-cross.py +""" + +import sys + +from auditwheel.architecture import Architecture +from auditwheel.libc import Libc + +_arch = Architecture(sys.argv[1]) +_libc = {"glibc": Libc.GLIBC, "musl": Libc.MUSL}[sys.argv[2]] +del sys.argv[1:3] + +Architecture.detect = staticmethod(lambda *, bits=None: _arch) +Libc.detect = staticmethod(lambda: _libc) + +from auditwheel.main import main # noqa: E402 (import after patching detection) + +sys.exit(main()) diff --git a/scripts/autolint b/scripts/autolint deleted file mode 100755 index a2e9b01fc..000000000 --- a/scripts/autolint +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env python - -import argparse -import fnmatch -import os -import re -import sys - -import autopep8 -import editorconfig -import lib2to3.refactor - - -SOURCE_ROOTS = ['av', 'docs', 'examples', 'include', 'scratchpad', 'scripts', 'tests'] -SOURCE_EXTS = set('.py .pyx .pxd .rst'.split()) - - -def iter_source_paths(): - for root in SOURCE_ROOTS: - for dir_path, _, file_names in os.walk(root): - for file_name in file_names: - base, ext = os.path.splitext(file_name) - if base.startswith('.'): - continue - if ext not in SOURCE_EXTS: - continue - yield os.path.abspath(os.path.join(dir_path, file_name)) - - -def apply_editorconfig(source, path): - - config = editorconfig.get_properties(path) - - soft_indent = config.get('indent_style', 'space') == 'space' - indent_size = int(config.get('indent_size', 4)) - do_trim = config['trim_trailing_whitespace'] == 'true' - do_final_newline = config['insert_final_newline'] == 'true' - - spaced_indent = ' ' * indent_size - - output = [] - - for line in source.splitlines(): - - # Apply trim_trailing_whitespace. - if do_trim: - line = line.rstrip() - - # Adapt tabs to/from spaces. - m = re.match(r'(\s+)(.*)', line) - if m: - indent, content = m.groups() - if soft_indent: - indent.replace('\t', spaced_indent) - else: - indent.replace(indent, '\t') - line = indent + content - - output.append(line) - - while output and not output[-1]: - output.pop() - - if do_final_newline: - output.append('') - - return '\n'.join(output) - - -pep8_fixes_by_ext = { - pattern: tuple(filter(None, (x.split('#')[0].split('-')[0].strip() for x in value.splitlines()))) - for pattern, value in { - '*': ''' - E121 - Fix indentation to be a multiple of four. - E122 - Add absent indentation for hanging indentation. - ''', - '.py': ''' - E226 - Fix missing whitespace around arithmetic operator. - E227 - Fix missing whitespace around bitwise/shift operator. - E228 - Fix missing whitespace around modulo operator. - E231 - Add missing whitespace. - E242 - Remove extraneous whitespace around operator. - W603 - Use "!=" instead of "<>" - W604 - Use "repr()" instead of backticks. - - E22 - Fix extraneous whitespace around keywords. # Messes with Cython. - E241 - Fix extraneous whitespace around keywords. # Messes with Cython. - - ''', - '.py*': ''' - E224 - Remove extraneous whitespace around operator. - # E301 - Add missing blank line. - # E302 - Add missing 2 blank lines. - # E303 - Remove extra blank lines. - E251 - Remove whitespace around parameter '=' sign. - E304 - Remove blank line following function decorator. - E401 - Put imports on separate lines. - E20 - Remove extraneous whitespace. - E211 - Remove extraneous whitespace. - ''', - }.items() -} - -def apply_autopep8(source, path): - - fixes = set() - ext = os.path.splitext(path)[1] - for pattern in pep8_fixes_by_ext: - if fnmatch.fnmatch(ext, pattern): - fixes.update(pep8_fixes_by_ext[pattern]) - - source = autopep8.fix_code(source, options=dict( - select=filter(None, fixes), - )) - - return source - - - -def apply_future(source, path): - - if os.path.splitext(path)[1] not in ('.py', ): - return source - - m = re.search(r'^from __future__ import ([\w, \t]+)', source, flags=re.MULTILINE) - if m: - features = set(x.strip() for x in m.group(1).split(',')) - else: - features = set() - - fixes = [] - - if 'print_function' not in features and re.search(r'^\s*print\s+', source, flags=re.MULTILINE): - fixes.append('lib2to3.fixes.fix_print') - - if not fixes: - # Nothing to do. - return source - - # The parser chokes if the last line is not empty. - if not source.endswith('\n'): - source += '\n' - - tool = lib2to3.refactor.RefactoringTool(fixes) - tree = tool.refactor_string(source, path) - source = str(tree) - - if 'print' in source: - features.add('print_function') - - source = 'from __future__ import {}\n{}'.format(', '.join(sorted(features)), source) - - return source - - - - -def main(): - - parser = argparse.ArgumentParser() - parser.add_argument('-a', '--all', action='store_true') - parser.add_argument('-e', '--editorconfig', action='store_true') - parser.add_argument('-p', '--pep8', action='store_true') - parser.add_argument('-f', '--future', action='store_true') - args = parser.parse_args() - - if not (args.all or args.editorconfig or args.pep8 or args.future): - print("Nothing to do.", file=sys.stderr) - parser.print_usage() - exit(1) - - for path in iter_source_paths(): - - before = after = open(path).read() - print(path) - - if args.all or args.pep8: - after = apply_autopep8(after, path) - - if args.all or args.future: - after = apply_future(after, path) - - if args.all or args.editorconfig: - after = apply_editorconfig(after, path) - - if before == after: - continue - - with open(path, 'w') as fh: - fh.write(after) - - -if __name__ == '__main__': - main() diff --git a/scripts/build b/scripts/build index d860c65a3..dff74a880 100755 --- a/scripts/build +++ b/scripts/build @@ -1,10 +1,4 @@ -#!/bin/bash - -if [[ "$TRAVIS" && ("$TESTSUITE" == "isort" || "$TESTSUITE" == "flake8") ]]; then - echo "We don't need to build PyAV for source linting." - exit 0 -fi - +#! /usr/bin/env bash if [[ ! "$_PYAV_ACTIVATED" ]]; then export here="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" @@ -23,8 +17,5 @@ echo PKG_CONFIG_PATH: $PKG_CONFIG_PATH echo LD_LIBRARY_PATH: $LD_LIBRARY_PATH echo -which ffmpeg || exit 2 -ffmpeg -version || exit 3 -echo - +$PYAV_PIP install -U --pre cython setuptools 2> /dev/null "$PYAV_PYTHON" setup.py config build_ext --inplace || exit 1 diff --git a/scripts/build-armv7l-cross.sh b/scripts/build-armv7l-cross.sh new file mode 100755 index 000000000..f7e97ec42 --- /dev/null +++ b/scripts/build-armv7l-cross.sh @@ -0,0 +1,140 @@ +#! /usr/bin/env bash +# +# Cross-compile PyAV's armv7l (32-bit ARM) wheels with zig on an x86_64 host, +# without QEMU. Only PyAV's Cython->C extensions are compiled; FFmpeg is fetched +# prebuilt for armv7l. zig is the cross compiler, and CPython's cross-build env +# vars (_PYTHON_HOST_PLATFORM / _PYTHON_SYSCONFIGDATA_NAME) make sysconfig report +# armv7l without running any armv7l code. The target Python trees + system libs +# are copied out of the manylinux armv7l image with `docker cp` (no execution). +# +# Builds two manylinux (glibc) wheels: cp311-abi3 (covers 3.11-3.13) and cp314t. +# musllinux armv7l is skipped: the musl FFmpeg needs system libs (libdrm, +# libxcb*, libbz2) the musllinux image doesn't ship for auditwheel to bundle. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/.." && pwd)" +cd "$root" + +# Host interpreters that drive each build (must match the wheel's Python: 3.11 +# emits cp311-abi3, 3.14t emits cp314t). zig/auditwheel run from TOOLS_PY. +HOST_PY311="${HOST_PY311:-python3.11}" +HOST_PY314T="${HOST_PY314T:-python3.14t}" +TOOLS_PY="${TOOLS_PY:-python3}" + +DIST_DIR="${DIST_DIR:-$root/dist}" +WORK_DIR="${WORK_DIR:-$root/build/armv7l-cross}" +IMAGE="quay.io/pypa/manylinux_2_31_armv7l" +TRIPLE="arm-linux-gnueabihf.2.31" # pin glibc to the manylinux_2_31 policy +PLAT="manylinux_2_31_armv7l" +FFMPEG_CONFIG="scripts/ffmpeg-latest.json" + +PY_DIR="$WORK_DIR/py" +BIN_DIR="$WORK_DIR/bin" +mkdir -p "$DIST_DIR" "$BIN_DIR" "$PY_DIR" + +# --- zig compiler wrappers ------------------------------------------------ +# setuptools invokes $CC / $LDSHARED as plain commands. Resolve the zig binary +# once and call it directly (not "python -m ziglang"): the build sets PYTHONPATH +# to the target stdlib, which would crash a per-compile Python. The wrappers also +# drop a few gcc-only flags clang (zig) rejects. +ZIG_BIN="$("$TOOLS_PY" -c 'import os, ziglang; print(os.path.join(os.path.dirname(ziglang.__file__), "zig"))')" + +write_cc() { + cat >"$1" <"$BIN_DIR/zig-ar" +chmod +x "$BIN_DIR/zig-ar" + +# --- copy the target Python trees + system libs out of the image ---------- +extract_image() { + if [[ -d "$PY_DIR/_internal" ]]; then + return # already extracted + fi + echo "Pulling $IMAGE" + docker pull --platform linux/arm/v7 "$IMAGE" + local cid + cid="$(docker create --platform linux/arm/v7 "$IMAGE")" + # /opt/python/ are symlinks into /opt/_internal/; copy both. Also + # grab the armhf system libs (libxcb, ...) FFmpeg needs so auditwheel can + # bundle them. docker cp reads the filesystem; it runs no armv7l code. + docker cp "$cid:/opt/python" "$PY_DIR" + docker cp "$cid:/opt/_internal" "$PY_DIR" + docker cp "$cid:/usr/lib/arm-linux-gnueabihf" "$PY_DIR/syslib" + docker rm -f "$cid" >/dev/null +} + +# --- build + repair one wheel --------------------------------------------- +# args: +build_one() { + local host_py="$1" py_glob="$2" + + # Resolve /opt/python/ symlink to its real tree under /opt/_internal. + local link + link="$(find "$PY_DIR/python" -maxdepth 1 -name "$py_glob" | head -1)" + [[ -n "$link" ]] || { echo "no python matching '$py_glob':" >&2; ls "$PY_DIR/python" >&2; exit 1; } + local pytree="$PY_DIR/_internal/$(basename "$(readlink "$link")")" + + local inc=( "$pytree"/include/python3.* ) + local scd=( "$pytree"/lib/python3.*/_sysconfigdata_*.py ) + echo "=== building for $(basename "$pytree") (host $host_py) ===" + + # FFmpeg's pkg-config files bake in prefix=/tmp/vendor, so extract there. + rm -rf /tmp/vendor + PYAV_VENDOR_PLATFORM=manylinux-armv7l "$TOOLS_PY" scripts/fetch-vendor.py \ + --config-file "$FFMPEG_CONFIG" /tmp/vendor + + local raw="$WORK_DIR/raw/$(basename "$pytree")" + rm -rf "$raw"; mkdir -p "$raw" + + # The _PYTHON_* vars make sysconfig report armv7l; zig does the compiling. + # max-page-size=4096 aligns our extensions to the armv7l (4K) page size; + # lld's default 64K alignment uses 4K file offsets, which the loader rejects. + # Setting CFLAGS replaces the target's default OPT flags, so -O2 -DNDEBUG + # must be passed explicitly (without NDEBUG the free-threaded build trips a + # Py_SET_REFCNT assertion that release wheels compile out). + env \ + CC="$BIN_DIR/zig-cc" CXX="$BIN_DIR/zig-cxx" AR="$BIN_DIR/zig-ar" \ + LDSHARED="$BIN_DIR/zig-cc -shared -Wl,-z,max-page-size=4096" \ + _PYTHON_HOST_PLATFORM=linux-armv7l \ + _PYTHON_SYSCONFIGDATA_NAME="$(basename "${scd[0]}" .py)" \ + PYTHONPATH="$(dirname "${scd[0]}")" \ + CFLAGS="-I${inc[0]} -O2 -DNDEBUG" \ + PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig \ + LD_LIBRARY_PATH=/tmp/vendor/lib \ + "$host_py" -m pip wheel . --no-build-isolation --no-deps -w "$raw" + + # Bundle FFmpeg + its armhf system deps and stamp the platform tag. The shim + # lets auditwheel accept the armv7l --plat from an x86_64 host; --ldpaths + # points it at the target's libs instead of the host search path. Force the + # pip patchelf (<0.18, on PATH via the scripts dir) ahead of the system one: + # ubuntu-24.04 ships patchelf 0.18.0, which silently corrupts ELF files with + # large p_align, breaking the bundled FFmpeg libs at runtime. + local pe_dir + pe_dir="$("$TOOLS_PY" -c 'import sysconfig; print(sysconfig.get_path("scripts"))')" + PATH="$pe_dir:$PATH" "$TOOLS_PY" scripts/auditwheel-cross.py armv7l glibc repair \ + --ldpaths "/tmp/vendor/lib:$PY_DIR/syslib" \ + --plat "$PLAT" -w "$DIST_DIR" "$raw"/*.whl +} + +extract_image +build_one "$HOST_PY311" "cp311-cp311" +build_one "$HOST_PY314T" "cp314*-cp314t" + +echo +echo "Built armv7l wheels:" +ls -1 "$DIST_DIR"/*armv7l*.whl diff --git a/scripts/build-debug-python b/scripts/build-debug-python deleted file mode 100755 index 757946f14..000000000 --- a/scripts/build-debug-python +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -export PYAV_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.."; pwd)" - -export PYAV_PYTHON_VERSION="2.7.13" -export PYAV_PLATFORM_SLUG="$(uname -s).$(uname -r)" -export PYAV_VENV_NAME="$PYAV_PLATFORM_SLUG.cpython-$PYAV_PYTHON_VERSION-debug" -export PYAV_VENV="$PYAV_ROOT/venvs/$PYAV_VENV_NAME" - - -export PYAV_PYTHON_SRC="$PYAV_ROOT/vendor/Python-$PYAV_PYTHON_VERSION" - -if [[ ! -d "$PYAV_PYTHON_SRC" ]]; then - url="https://www.python.org/ftp/python/$PYAV_PYTHON_VERSION/Python-$PYAV_PYTHON_VERSION.tgz" - echo "Downloading $url" - wget -O "$PYAV_PYTHON_SRC.tgz" "$url" || exit 2 - tar -C "$PYAV_ROOT/vendor" -xvzf "$PYAV_PYTHON_SRC.tgz" || exit 3 -fi - -cd "$PYAV_PYTHON_SRC" || exit 4 - -# TODO: Make generic. -export CPPFLAGS="-I$(brew --prefix openssl)/include" -export LDFLAGS="-L$(brew --prefix openssl)/lib" - -# --with-pymalloc \ -./configure \ - --with-pydebug \ - --prefix "$PYAV_VENV" \ - || exit 5 - -make -j 12 || exit 6 - diff --git a/scripts/build-deps b/scripts/build-deps index e941d0c6b..8ff3415c0 100755 --- a/scripts/build-deps +++ b/scripts/build-deps @@ -1,4 +1,4 @@ -#!/bin/bash +#! /usr/bin/env bash if [[ ! "$_PYAV_ACTIVATED" ]]; then export here="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" @@ -7,32 +7,45 @@ fi cd "$PYAV_ROOT" -# Always try to install the Python dependencies they are cheap. -$PYAV_PIP install --upgrade -r tests/requirements.txt - - -if [[ "$TRAVIS" && ("$TESTSUITE" == "isort" || "$TESTSUITE" == "flake8") ]]; then - echo "We don't need to build dependencies for source linting." - exit 0 -fi - # Skip the rest of the build if it already exists. if [[ -e "$PYAV_LIBRARY_PREFIX/bin/ffmpeg" ]]; then - echo "We have a cached build of $PYAV_LIBRARY; skipping re-build." + echo "We have a cached build of ffmpeg-$PYAV_LIBRARY; skipping re-build." exit 0 fi - mkdir -p "$PYAV_LIBRARY_ROOT" mkdir -p "$PYAV_LIBRARY_PREFIX" -cd "$PYAV_LIBRARY_ROOT" +# Add CUDA support if available +CONFFLAGS_NVIDIA="" +if [[ -e /usr/local/cuda ]]; then + # Get Nvidia headers for ffmpeg + cd $PYAV_LIBRARY_ROOT + if [[ ! -e "$PYAV_LIBRARY_ROOT/nv-codec-headers" ]]; then + git clone https://github.com/FFmpeg/nv-codec-headers.git + cd nv-codec-headers + make -j4 + make PREFIX="$PYAV_LIBRARY_PREFIX" install + fi + + PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH" + CONFFLAGS_NVIDIA="--enable-cuda-nvcc \ + --enable-nonfree \ + --enable-libnpp \ + --extra-cflags=-I/usr/local/cuda/include \ + --extra-ldflags=-L/usr/local/cuda/lib64" +else + echo "WARNING: Did not find cuda libraries in /usr/local/cuda..." + echo " Building without NVIDIA NVENC/NVDEC support" +fi + +cd "$PYAV_LIBRARY_ROOT" # Download and expand the source. if [[ ! -d $PYAV_LIBRARY ]]; then url="https://ffmpeg.org/releases/$PYAV_LIBRARY.tar.gz" echo Downloading $url - wget --no-check-certificate "$url" || exit 1 + curl "$url" --output ${PYAV_LIBRARY}.tar.gz || exit 1 tar -xzf $PYAV_LIBRARY.tar.gz rm $PYAV_LIBRARY.tar.gz echo @@ -42,14 +55,25 @@ cd $PYAV_LIBRARY echo ./configure ./configure \ --disable-doc \ - --disable-mmx \ - --disable-optimizations \ - --disable-static \ + --disable-programs \ --disable-stripping \ - --enable-debug=3 \ + --disable-static \ + --enable-shared \ --enable-gpl \ + --enable-version3 \ + --disable-libxml2 \ + --disable-xlib \ + --disable-openssl \ + --enable-debug=3 \ --enable-libx264 \ - --enable-shared \ + --disable-bsfs \ + --enable-bsf=chomp,extract_extradata,h264_mp4toannexb,setts \ + --disable-filters \ + --enable-filter=abuffer,abuffersink,aformat,aresample,atempo,buffer,buffersink,bwdif,color,ebur128,loudnorm,lutrgb,overlay,palettegen,scale,testsrc,vflip,volume \ + --enable-sse \ + --enable-avx \ + --enable-avx2 \ + $CONFFLAGS_NVIDIA \ --prefix="$PYAV_LIBRARY_PREFIX" \ || exit 2 echo diff --git a/scripts/clean-branches b/scripts/clean-branches deleted file mode 100755 index 83f82f6fb..000000000 --- a/scripts/clean-branches +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python - -import subprocess - - -# These are remote branches that got rebased or something, but I don't have -# control over. -ignored_hashes = set(''' - 078daa1148a84849ea0890b388353acd7333fcea - 0832d2bdaa048fca1393f3d3810b8b8810535f9f - 08e86996736d77df7d694d0a67a126fc7eac7e94 - 097631535fa4cdb87204980541d3f9bb9c7a9ffb - 16ce34ba1d1f1ed4327326a10565f1a9f07107b0 - 183cf1447571aa48baf0665d17d41be1e48f2cc6 - 245a4dca69cf0fff674c6ad5bcfb7929c7347bf6 - 28b4b3988981471ff173679db3643418ff3f5aaa - 3977b5d5be22922f2eb4288e2682f1de8fad8e12 - 5b9f192165855942918f9bd957c30e918b97cbeb - 6091e89de0ae4aff2ba76d21b1110409ef174b78 - 636afe3f0b5b07233edae8e333db35c044c36b30 - 74f79ef74ec281f5e0da51bcfd0b1051aa53edbf - 7737ef6e9e7307c40f326e61cc9291047540bc49 - 8618940d333f44ff960d561dda34167d4dbb81d4 - a2fb55e97788809b5f33b1b0c9241fc77312f606 - aa044b3f62a6d7bf4dde18cba91b1d0dd8a0816a - aa7d01ba458025ede1757e56b638002375bb864a - aafe064e209b667f565c4f57a94b098474d0b184 - afac2d8f89673c012d1f4b845b006911f55d1d86 - b115786b950c87ef9c422752e014297903bca393 - b737c6ceb6750d00f62dfdaa40fee3e757c680a3 - b7bf427a485736e6e1c71605bdce101214bae09f - ba02afa7ea160328b5a3be111c7e276fb9d3c961 - bc5ffe456345286a64ce33ffe5ce6a2ee8b63f40 - c45a337fe49875b1cc28a0501a704890be444765 - c6b1a5ac03e775ea46bffac7bbfea9d73cd03b87 - c9c0d63b09c450d494fba1c4073fbe18851dfaff - cc270d6790c02e6c5e93313d1e6499ce534350b9 - cdd8e4c085a55e258bd551f7bcf4fee60474aa05 - eac71881c24d42f801e9c18e448855a402333960 - efd12926b1f446c32f5a239c0b2d351fa2d78101 - f04dce0e80b4f290482eba4fb3c3ec68f353bf01 - f0d1e82dee788085cf4afad7656a90966e40f7a0 - f518f6e7bf47e00fe0c73a5098ae40813920400f - f779c4371fdace76ee572053b4acb3999ffd4107 -'''.strip().split()) - - -def get_branches(*args): - cmd = ['git', 'branch', '-v', '--abbrev=40'] - cmd.extend(args) - res = {} - for line in subprocess.check_output(cmd).decode().splitlines(): - parts = line[2:].strip().split() - name = parts[0] - hash_ = parts[1] - res[name] = hash_ - return res - -def rm(*args): - subprocess.check_call(('git', 'branch', '-D') + args) - - -# Clean up everything that was merged -for line in subprocess.check_output(['git', 'branch', '--merged']).decode().splitlines(): - line = line.strip() - if not line: - continue - parts = line.split() - if parts[0] == '*': - continue - if parts[-1] in ('develop', 'master'): - continue - rm(parts[-1]) - -for line in subprocess.check_output(['git', 'branch', '-r', '--merged']).decode().splitlines(): - name = line.strip().split()[-1] - if not name: - continue - if name.split('/', 1)[-1] in ('develop', 'master'): - continue - rm('-r', name) - - -our_branches = get_branches() -for name, hash_ in get_branches('-r').items(): - - if hash_ in ignored_hashes: - print("Removing ignored", name) - rm('-r', name) - continue - - if name.startswith('origin/'): - our_branches[name] = hash_ - - -for name in get_branches('-r', '--merged'): - if name.startswith('origin/'): - continue - print("Removing merged", name) - rm('-r', name) - -for name, hash_ in get_branches('-r', '--no-merged').items(): - - remote, branch = name.split('/', 1) - if remote == 'origin': - continue - - for prefix in '', 'origin/': - our_name = prefix + branch - if our_branches.get(our_name) == hash_: - print("Removing identical", name) - rm('-r', name) - break - -# Anything that doesn't root at the same place as us. -for name in get_branches('-r', '--no-contains', 'e105c0b4e64a0471f3f5375a86342c33cb942e23'): - rm('-r', name) - - - diff --git a/scripts/fetch-vendor b/scripts/fetch-vendor deleted file mode 100755 index aa354aaba..000000000 --- a/scripts/fetch-vendor +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python - -import argparse -import logging -import json -import os -import shutil -import struct -import subprocess -import sys - - -def get_platform(): - if sys.platform == "linux": - return "manylinux_%s" % os.uname().machine - elif sys.platform == "darwin": - return "macosx_%s" % os.uname().machine - elif sys.platform == "win32": - return "win%s" % (struct.calcsize("P") * 8) - else: - raise Exception("Unsupported platfom %s" % sys.platform) - - -parser = argparse.ArgumentParser(description="Fetch and extract tarballs") -parser.add_argument("destination_dir") -parser.add_argument("--cache-dir", default="tarballs") -parser.add_argument("--config-file", default=__file__ + ".json") -args = parser.parse_args() -logging.basicConfig(level=logging.INFO) - -# read config file -with open(args.config_file, "r") as fp: - config = json.load(fp) - -# create fresh destination directory -logging.info("Creating directory %s" % args.destination_dir) -if os.path.exists(args.destination_dir): - shutil.rmtree(args.destination_dir) -os.mkdir(args.destination_dir) - -for url_template in config["urls"]: - tarball_url = url_template.replace("{platform}", get_platform()) - - # download tarball - tarball_name = tarball_url.split("/")[-1] - tarball_file = os.path.join(args.cache_dir, tarball_name) - if not os.path.exists(tarball_file): - logging.info("Downloading %s" % tarball_url) - if not os.path.exists(args.cache_dir): - os.mkdir(args.cache_dir) - subprocess.check_call( - ["curl", "--location", "--output", tarball_file, "--silent", tarball_url] - ) - - # extract tarball - logging.info("Extracting %s" % tarball_name) - subprocess.check_call(["tar", "-C", args.destination_dir, "-xf", tarball_file]) diff --git a/scripts/fetch-vendor.json b/scripts/fetch-vendor.json deleted file mode 100644 index 95829f1eb..000000000 --- a/scripts/fetch-vendor.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "urls": ["https://github.com/PyAV-Org/pyav-ffmpeg/releases/download/4.3.1-1/ffmpeg-{platform}.tar.gz"] -} diff --git a/scripts/fetch-vendor.py b/scripts/fetch-vendor.py new file mode 100644 index 000000000..b046c1884 --- /dev/null +++ b/scripts/fetch-vendor.py @@ -0,0 +1,70 @@ +import argparse +import logging +import json +import os +import platform +import subprocess +import time + + +def get_platform(): + # Allow forcing the target platform so we can fetch e.g. an armv7l build + # while running on an x86_64 host (cross-compilation). + forced = os.environ.get("PYAV_VENDOR_PLATFORM") + if forced: + return forced + + system = platform.system() + machine = platform.machine().lower() + is_arm64 = machine in {"arm64", "aarch64"} + if system == "Linux": + prefix = "manylinux-" if platform.libc_ver()[0] == "glibc" else "musllinux-" + return prefix + machine + elif system == "Darwin": + return "macos-arm64" if is_arm64 else "macos-x86_64" + elif system == "Windows": + return "windows-aarch64" if is_arm64 else "windows-x86_64" + else: + return "unknown" + + +parser = argparse.ArgumentParser(description="Fetch and extract tarballs") +parser.add_argument("destination_dir") +parser.add_argument("--cache-dir", default="tarballs") +parser.add_argument("--config-file", default=os.path.splitext(__file__)[0] + ".json") +args = parser.parse_args() +logging.basicConfig(level=logging.INFO) + +with open(args.config_file) as fp: + config = json.load(fp) + +# ensure destination directory exists +logging.info(f"Creating directory {args.destination_dir}") +if not os.path.exists(args.destination_dir): + os.makedirs(args.destination_dir) + +tarball_url = config["url"].replace("{platform}", get_platform()) + +# download tarball +tarball_name = tarball_url.split("/")[-1] +tarball_file = os.path.join(args.cache_dir, tarball_name) +if not os.path.exists(tarball_file): + logging.info(f"Downloading {tarball_url}") + if not os.path.exists(args.cache_dir): + os.mkdir(args.cache_dir) + subprocess.check_call( + ["curl", "--location", "--output", tarball_file, "--silent", tarball_url] + ) + +logging.info(f"Extracting {tarball_name}") +subprocess.check_call(["tar", "-C", args.destination_dir, "-xf", tarball_file]) + +# Some tarball members carry pre-1980 mtimes, which the ZIP format (and thus +# delvewheel's wheel repackaging) cannot represent. Bump any such file to now. +ZIP_EPOCH = 315532800 # 1980-01-01 00:00:00 UTC +now = time.time() +for root, _, files in os.walk(args.destination_dir): + for name in files: + path = os.path.join(root, name) + if os.path.getmtime(path) < ZIP_EPOCH: + os.utime(path, (now, now)) diff --git a/scripts/ffmpeg-8.0.json b/scripts/ffmpeg-8.0.json new file mode 100644 index 000000000..a7df98bb9 --- /dev/null +++ b/scripts/ffmpeg-8.0.json @@ -0,0 +1,3 @@ +{ + "url": "https://github.com/PyAV-Org/pyav-ffmpeg/releases/download/8.0.1-5/ffmpeg-{platform}.tar.gz" +} diff --git a/scripts/ffmpeg-8.1.json b/scripts/ffmpeg-8.1.json new file mode 100644 index 000000000..58dc5ae29 --- /dev/null +++ b/scripts/ffmpeg-8.1.json @@ -0,0 +1,3 @@ +{ + "url": "https://github.com/PyAV-Org/pyav-ffmpeg/releases/download/8.1.2-1/ffmpeg-{platform}.tar.gz" +} diff --git a/scripts/ffmpeg-latest.json b/scripts/ffmpeg-latest.json new file mode 100644 index 000000000..58dc5ae29 --- /dev/null +++ b/scripts/ffmpeg-latest.json @@ -0,0 +1,3 @@ +{ + "url": "https://github.com/PyAV-Org/pyav-ffmpeg/releases/download/8.1.2-1/ffmpeg-{platform}.tar.gz" +} diff --git a/scripts/inject-dll b/scripts/inject-dll deleted file mode 100755 index b382b4547..000000000 --- a/scripts/inject-dll +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python - -import argparse -import logging -import os -import shutil -import zipfile - -parser = argparse.ArgumentParser(description="Inject DLLs into a Windows binary wheel") -parser.add_argument( - "wheel", type=str, help="the source wheel to which DLLs should be added", -) -parser.add_argument( - "dest_dir", type=str, help="the directory where to create the repaired wheel", -) -parser.add_argument( - "dll_dir", type=str, help="the directory containing the DLLs", -) - -args = parser.parse_args() -wheel_name = os.path.basename(args.wheel) -package_name = wheel_name.split("-")[0] -repaired_wheel = os.path.join(args.dest_dir, wheel_name) - -logging.basicConfig(level=logging.INFO) -logging.info("Copying '%s' to '%s'", args.wheel, repaired_wheel) -shutil.copy(args.wheel, repaired_wheel) - -logging.info("Adding DLLs from '%s' to package '%s'", args.dll_dir, package_name) -with zipfile.ZipFile(repaired_wheel, mode="a", compression=zipfile.ZIP_DEFLATED) as wheel: - for name in sorted(os.listdir(args.dll_dir)): - if name.lower().endswith(".dll"): - local_path = os.path.join(args.dll_dir, name) - archive_path = os.path.join(package_name, name) - if archive_path not in wheel.namelist(): - logging.info("Adding '%s' as '%s'", local_path, archive_path) - wheel.write(local_path, archive_path) diff --git a/scripts/test b/scripts/test index e7503a0d7..e91288baf 100755 --- a/scripts/test +++ b/scripts/test @@ -1,4 +1,4 @@ -#!/bin/bash +#! /usr/bin/env bash # Exit as soon as something errors. set -e @@ -18,31 +18,10 @@ istest() { return $? } -if istest flake8; then - # Settings are in setup.cfg - $PYAV_PYTHON -m flake8 av examples tests -fi - -if istest isort; then - # More settings in setup.cfg - $PYAV_PYTHON -m isort --check-only --diff av examples tests -fi +$PYAV_PYTHON -c "import av; print(f'PyAV: {av.__version__}'); print(f'FFMPEG: {av.ffmpeg_version_info}')" if istest main; then - $PYAV_PYTHON setup.py test -fi - -if istest sdist; then - $PYAV_PYTHON setup.py build_ext - $PYAV_PYTHON setup.py sdist - if [[ "$TRAVIS_TAG" ]]; then - $PYAV_PIP install twine - $PYAV_PYTHON -m twine upload --skip-existing dist/* - fi -fi - -if istest doctest; then - make -C docs test + $PYAV_PYTHON -m pytest fi if istest examples; then diff --git a/scripts/vagrant-test b/scripts/vagrant-test deleted file mode 100755 index dbdab607a..000000000 --- a/scripts/vagrant-test +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -cd /vagrant - -./scripts/build-deps -./scripts/build -./scripts/test diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f4980c82b..000000000 --- a/setup.cfg +++ /dev/null @@ -1,23 +0,0 @@ -[flake8] -filename = *.py,*.pyx,*.pxd -max-line-length = 142 -per-file-ignores = __init__.py: E402,F401 *.pyx,*.pxd: E211,E225,E227,E402,E999 - -[isort] -sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -lines_after_imports = 2 -skip = av/__init__.py -known_first_party = av -default_section = THIRDPARTY -from_first = 1 -multi_line_output = 3 - -[metadata] -license = BSD -long_description = file: README.md -long_description_content_type = text/markdown -project_urls = - Bug Reports = https://github.com/PyAV-Org/PyAV/issues - Documentation = https://pyav.org/docs - Feedstock = https://github.com/conda-forge/av-feedstock - Download = https://pypi.org/project/av diff --git a/setup.py b/setup.py index e4135425f..872c5f8e8 100644 --- a/setup.py +++ b/setup.py @@ -1,268 +1,184 @@ -from distutils.ccompiler import new_compiler as _new_compiler -from distutils.command.clean import clean, log -from distutils.core import Command -from distutils.dir_util import remove_tree -from distutils.errors import DistutilsExecError -from distutils.msvccompiler import MSVCCompiler -from setuptools import setup, find_packages, Extension, Distribution -from setuptools.command.build_ext import build_ext -from shlex import quote -from subprocess import Popen, PIPE import argparse -import errno import os +import pathlib import platform import re import shlex +import subprocess import sys -try: - # This depends on _winreg, which is not available on not-Windows. - from distutils.msvc9compiler import MSVCCompiler as MSVC9Compiler -except ImportError: - MSVC9Compiler = None -try: - from distutils._msvccompiler import MSVCCompiler as MSVC14Compiler -except ImportError: - MSVC14Compiler = None - -try: - from Cython import __version__ as cython_version - from Cython.Build import cythonize -except ImportError: - cythonize = None +from Cython.Build import cythonize +from Cython.Compiler.AutoDocTransforms import EmbedSignature +from setuptools import Extension, find_packages, setup + +FFMPEG_LIBRARIES = [ + "avformat", + "avcodec", + "avdevice", + "avutil", + "avfilter", + "swscale", + "swresample", +] + +if sys.implementation.name == "cpython" and (3, 14) > sys.version_info > (3, 11): + py_limited_api = True + options = {"bdist_wheel": {"py_limited_api": "cp311"}} + define_macros = [("Py_LIMITED_API", 0x030B0000)] else: - # We depend upon some features in Cython 0.27; reject older ones. - if tuple(map(int, cython_version.split('.'))) < (0, 27): - print("Cython {} is too old for PyAV; ignoring it.".format(cython_version)) - cythonize = None - - -# We will embed this metadata into the package so it can be recalled for debugging. -version = open('VERSION.txt').read().strip() -try: - git_commit, _ = Popen(['git', 'describe', '--tags'], stdout=PIPE, stderr=PIPE).communicate() -except OSError: - git_commit = None -else: - git_commit = git_commit.decode().strip() - - -_cflag_parser = argparse.ArgumentParser(add_help=False) -_cflag_parser.add_argument('-I', dest='include_dirs', action='append') -_cflag_parser.add_argument('-L', dest='library_dirs', action='append') -_cflag_parser.add_argument('-l', dest='libraries', action='append') -_cflag_parser.add_argument('-D', dest='define_macros', action='append') -_cflag_parser.add_argument('-R', dest='runtime_library_dirs', action='append') -def parse_cflags(raw_cflags): - raw_args = shlex.split(raw_cflags.strip()) - args, unknown = _cflag_parser.parse_known_args(raw_args) - config = {k: v or [] for k, v in args.__dict__.items()} - for i, x in enumerate(config['define_macros']): - parts = x.split('=', 1) - value = x[1] or None if len(x) == 2 else None - config['define_macros'][i] = (parts[0], value) - return config, ' '.join(quote(x) for x in unknown) - -def get_library_config(name): - """Get distutils-compatible extension extras for the given library. + py_limited_api = False + options = {} + define_macros = [] - This requires ``pkg-config``. +# Monkey-patch Cython to not overwrite embedded signatures. +old_embed_signature = EmbedSignature._embed_signature - """ - try: - proc = Popen(['pkg-config', '--cflags', '--libs', name], stdout=PIPE, stderr=PIPE) - except OSError: - print('pkg-config is required for building PyAV') - exit(1) - raw_cflags, err = proc.communicate() - if proc.wait(): - return +def new_embed_signature(self, sig, doc): + # Strip any `self` parameters from the front. + sig = re.sub(r"\(self(,\s+)?", "(", sig) - known, unknown = parse_cflags(raw_cflags.decode('utf8')) - if unknown: - print("pkg-config returned flags we don't understand: {}".format(unknown)) - exit(1) + # If they both start with the same signature; skip it. + if sig and doc: + new_name = sig.split("(")[0].strip() + old_name = doc.split("(")[0].strip() + if new_name == old_name: + return doc + if new_name.endswith("." + old_name): + return doc - return known + return old_embed_signature(self, sig, doc) -def update_extend(dst, src): - """Update the `dst` with the `src`, extending values where lists. +EmbedSignature._embed_signature = new_embed_signature - Primiarily useful for integrating results from `get_library_config`. +def get_config_from_directory(ffmpeg_dir): """ - for k, v in src.items(): - existing = dst.setdefault(k, []) - for x in v: - if x not in existing: - existing.append(x) - - -def unique_extend(a, *args): - a[:] = list(set().union(a, *args)) - - -# Obtain the ffmpeg dir from the "--ffmpeg-dir=" argument -FFMPEG_DIR = None -for i, arg in enumerate(sys.argv): - if arg.startswith('--ffmpeg-dir='): - FFMPEG_DIR = arg.split('=')[1] - break - -if FFMPEG_DIR is not None: - # delete the --ffmpeg-dir arg so that distutils does not see it - del sys.argv[i] - if not os.path.isdir(FFMPEG_DIR): - print('The specified ffmpeg directory does not exist') + Get distutils-compatible extension arguments for a specific directory. + """ + if not os.path.isdir(ffmpeg_dir): + print("The specified ffmpeg directory does not exist") exit(1) -else: - # Check the environment variable FFMPEG_DIR - FFMPEG_DIR = os.environ.get('FFMPEG_DIR') - if FFMPEG_DIR is not None: - if not os.path.isdir(FFMPEG_DIR): - FFMPEG_DIR = None - -if FFMPEG_DIR is not None: - ffmpeg_lib = os.path.join(FFMPEG_DIR, 'lib') - ffmpeg_include = os.path.join(FFMPEG_DIR, 'include') - if os.path.exists(ffmpeg_lib): - ffmpeg_lib = [ffmpeg_lib] - else: - ffmpeg_lib = [FFMPEG_DIR] - if os.path.exists(ffmpeg_include): - ffmpeg_include = [ffmpeg_include] - else: - ffmpeg_include = [FFMPEG_DIR] -else: - ffmpeg_lib = [] - ffmpeg_include = [] - - -# The "extras" to be supplied to every one of our modules. -# This is expanded heavily by the `config` command. -extension_extra = { - 'include_dirs': ['include'] + ffmpeg_include, # The first are PyAV's includes. - 'libraries' : [], - 'library_dirs': ffmpeg_lib, -} - -# The macros which describe the current PyAV version. -config_macros = { - "PYAV_VERSION": version, - "PYAV_VERSION_STR": '"%s"' % version, - "PYAV_COMMIT_STR": '"%s"' % (git_commit or 'unknown-commit'), -} - - -def dump_config(): - """Print out all the config information we have so far (for debugging).""" - print('PyAV:', version, git_commit or '(unknown commit)') - print('Python:', sys.version.encode('unicode_escape').decode()) - print('platform:', platform.platform()) - print('extension_extra:') - for k, vs in extension_extra.items(): - print('\t%s: %s' % (k, [x.encode('utf8') for x in vs])) - print('config_macros:') - for x in sorted(config_macros.items()): - print('\t%s=%s' % x) - -# Monkey-patch for CCompiler to be silent. -def _CCompiler_spawn_silent(cmd, dry_run=None): - """Spawn a process, and eat the stdio.""" - proc = Popen(cmd, stdout=PIPE, stderr=PIPE) - out, err = proc.communicate() - if proc.returncode: - raise DistutilsExecError(err) + include_dir = os.path.join(FFMPEG_DIR, "include") + library_dir = os.path.join(FFMPEG_DIR, "lib") + if not os.path.exists(include_dir): + include_dir = FFMPEG_DIR + if not os.path.exists(library_dir): + library_dir = FFMPEG_DIR -def new_compiler(*args, **kwargs): - """Create a C compiler. + return { + "include_dirs": [include_dir], + "libraries": FFMPEG_LIBRARIES, + "library_dirs": [library_dir], + } - :param bool silent: Eat all stdio? Defaults to ``True``. - - All other arguments passed to ``distutils.ccompiler.new_compiler``. +def get_config_from_pkg_config(): """ - make_silent = kwargs.pop('silent', True) - cc = _new_compiler(*args, **kwargs) - # If MSVC10, initialize the compiler here and add /MANIFEST to linker flags. - # See Python issue 4431 (https://bugs.python.org/issue4431) - if is_msvc(cc): - from distutils.msvc9compiler import get_build_version - if get_build_version() == 10: - cc.initialize() - for ldflags in [cc.ldflags_shared, cc.ldflags_shared_debug]: - unique_extend(ldflags, ['/MANIFEST']) - # If MSVC14, do not silence. As msvc14 requires some custom - # steps before the process is spawned, we can't monkey-patch this. - elif get_build_version() == 14: - make_silent = False - # monkey-patch compiler to suppress stdout and stderr. - if make_silent: - cc.spawn = _CCompiler_spawn_silent - return cc - - -_msvc_classes = tuple(filter(None, (MSVCCompiler, MSVC9Compiler, MSVC14Compiler))) -def is_msvc(cc=None): - cc = _new_compiler() if cc is None else cc - return isinstance(cc, _msvc_classes) - - -if os.name == 'nt': - - if is_msvc(): - config_macros['inline'] = '__inline' - - # Since we're shipping a self contained unit on Windows, we need to mark - # the package as such. On other systems, let it be universal. - class BinaryDistribution(Distribution): - def is_pure(self): - return False - - distclass = BinaryDistribution - -else: + Get distutils-compatible extension arguments using pkg-config. + """ + pkg_config = os.environ.get("PKG_CONFIG", "pkg-config") + try: + raw_cflags = subprocess.check_output( + [pkg_config, "--cflags", "--libs"] + + ["lib" + name for name in FFMPEG_LIBRARIES] + ) + except FileNotFoundError: + print(f"{pkg_config} is required for building PyAV") + exit(1) + except subprocess.CalledProcessError: + print(f"{pkg_config} could not find libraries {FFMPEG_LIBRARIES}") + exit(1) - # Nothing to see here. - distclass = Distribution + known, unknown = parse_cflags(raw_cflags.decode("utf-8")) + if unknown: + print(f"pkg-config returned flags we don't understand: {unknown}") + if "-pthread" in unknown: + print("Building PyAV against static FFmpeg libraries is not supported.") + exit(1) + return known -# Monkey-patch Cython to not overwrite embedded signatures. -if cythonize: - from Cython.Compiler.AutoDocTransforms import EmbedSignature +def parse_cflags(raw_flags): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("-I", dest="include_dirs", action="append") + parser.add_argument("-L", dest="library_dirs", action="append") + parser.add_argument("-l", dest="libraries", action="append") + parser.add_argument("-D", dest="define_macros", action="append") + parser.add_argument("-R", dest="runtime_library_dirs", action="append") - old_embed_signature = EmbedSignature._embed_signature - def new_embed_signature(self, sig, doc): + raw_args = shlex.split(raw_flags.strip()) + args, unknown = parser.parse_known_args(raw_args) + config = {k: v or [] for k, v in args.__dict__.items()} + for i, x in enumerate(config["define_macros"]): + parts = x.split("=", 1) + value = x[1] or None if len(x) == 2 else None + config["define_macros"][i] = (parts[0], value) + return config, " ".join(shlex.quote(x) for x in unknown) - # Strip any `self` parameters from the front. - sig = re.sub(r'\(self(,\s+)?', '(', sig) - # If they both start with the same signature; skip it. - if sig and doc: - new_name = sig.split('(')[0].strip() - old_name = doc.split('(')[0].strip() - if new_name == old_name: - return doc - if new_name.endswith('.' + old_name): - return doc +# Parse command-line arguments. +FFMPEG_DIR = None +for i, arg in enumerate(sys.argv): + if arg.startswith("--ffmpeg-dir="): + FFMPEG_DIR = arg.split("=")[1] + del sys.argv[i] - return old_embed_signature(self, sig, doc) +# Locate ffmpeg libraries and headers. +if FFMPEG_DIR is not None: + extension_extra = get_config_from_directory(FFMPEG_DIR) +elif platform.system() != "Windows": + extension_extra = get_config_from_pkg_config() +else: + extension_extra = { + "include_dirs": [], + "libraries": FFMPEG_LIBRARIES, + "library_dirs": [], + } + +IMPORT_NAME = "av" + +loudnorm_extension = Extension( + f"{IMPORT_NAME}.filter.loudnorm", + sources=[ + f"{IMPORT_NAME}/filter/loudnorm.py", + f"{IMPORT_NAME}/filter/loudnorm_impl.c", + ], + include_dirs=[f"{IMPORT_NAME}/filter"] + extension_extra["include_dirs"], + libraries=extension_extra["libraries"], + library_dirs=extension_extra["library_dirs"], + define_macros=define_macros, + py_limited_api=py_limited_api, +) - EmbedSignature._embed_signature = new_embed_signature +compiler_directives = { + "c_string_type": "str", + "c_string_encoding": "utf8", + "embedsignature": True, + "binding": False, + "language_level": 3, + "freethreading_compatible": True, +} +# Add the cythonized loudnorm extension to ext_modules +ext_modules = cythonize( + loudnorm_extension, + compiler_directives=compiler_directives, + build_dir="src", + include_path=["include"], +) -# Construct the modules that we find in the "av" directory. -ext_modules = [] -for dirname, dirnames, filenames in os.walk('av'): +for dirname, dirnames, filenames in os.walk(IMPORT_NAME): for filename in filenames: - - # We are looing for Cython sources. - if filename.startswith('.') or os.path.splitext(filename)[1] != '.pyx': + # We are looking for Cython sources. + if filename.startswith("."): + continue + if filename in {"__init__.py", "__main__.py", "about.py", "datasets.py"}: + continue + if os.path.splitext(filename)[1] not in {".pyx", ".py"}: continue pyx_path = os.path.join(dirname, filename) @@ -270,274 +186,33 @@ def new_embed_signature(self, sig, doc): # Need to be a little careful because Windows will accept / or \ # (where os.sep will be \ on Windows). - mod_name = base.replace('/', '.').replace(os.sep, '.') - - c_path = os.path.join('src', base + '.c') - - # We go with the C sources if Cython is not installed, and fail if - # those also don't exist. We can't `cythonize` here though, since the - # `pyav/include.h` must be generated (by `build_ext`) first. - if not cythonize and not os.path.exists(c_path): - print('Cython is required to build PyAV from raw sources.') - print('Please `pip install Cython`.') - exit(3) - ext_modules.append(Extension( - mod_name, - sources=[c_path if not cythonize else pyx_path], - )) - - -class ConfigCommand(Command): - - user_options = [ - ('no-pkg-config', None, - "do not use pkg-config to configure dependencies"), - ('verbose', None, - "dump out configuration"), - ('compiler=', 'c', - "specify the compiler type"), ] - - boolean_options = ['no-pkg-config'] - - def initialize_options(self): - self.compiler = None - self.no_pkg_config = None - - def finalize_options(self): - self.set_undefined_options('build', - ('compiler', 'compiler'),) - self.set_undefined_options('build_ext', - ('no_pkg_config', 'no_pkg_config'),) - - def run(self): - - # For some reason we get the feeling that CFLAGS is not respected, so we parse - # it here. TODO: Leave any arguments that we can't figure out. - for name in 'CFLAGS', 'LDFLAGS': - known, unknown = parse_cflags(os.environ.pop(name, '')) - if unknown: - print("Warning: We don't understand some of {} (and will leave it in the envvar): {}".format(name, unknown)) - os.environ[name] = unknown - update_extend(extension_extra, known) - - if is_msvc(new_compiler(compiler=self.compiler)): - # Assume we have to disable /OPT:REF for MSVC with ffmpeg - config = { - 'extra_link_args': ['/OPT:NOREF'], - } - update_extend(extension_extra, config) - - # Check if we're using pkg-config or not - if self.no_pkg_config: - # Simply assume we have everything we need! - config = { - 'libraries': ['avformat', 'avcodec', 'avdevice', 'avutil', 'avfilter', - 'swscale', 'swresample'], - 'library_dirs': [], - 'include_dirs': [] - } - update_extend(extension_extra, config) - for ext in self.distribution.ext_modules: - for key, value in extension_extra.items(): - setattr(ext, key, value) - return - - # We're using pkg-config: - errors = [] - - # Get the config for the libraries that we require. - for name in 'libavformat', 'libavcodec', 'libavdevice', 'libavutil', 'libavfilter', 'libswscale', 'libswresample': - config = get_library_config(name) - if config: - update_extend(extension_extra, config) - # We don't need macros for these, since they all must exist. - else: - errors.append('Could not find ' + name + ' with pkg-config.') - - if self.verbose: - dump_config() - - # Don't continue if we have errors. - # TODO: Warn Ubuntu 12 users that they can't satisfy requirements with the - # default package sources. - if errors: - print('\n'.join(errors)) - exit(1) - - # Normalize the extras. - extension_extra.update( - dict((k, sorted(set(v))) for k, v in extension_extra.items()) + mod_name = base.replace("/", ".").replace(os.sep, ".") + + # Cythonize the module. + ext_modules += cythonize( + Extension( + mod_name, + include_dirs=extension_extra["include_dirs"], + libraries=extension_extra["libraries"], + library_dirs=extension_extra["library_dirs"], + sources=[pyx_path], + define_macros=define_macros, + py_limited_api=py_limited_api, + ), + compiler_directives=compiler_directives, + build_dir="src", + include_path=["include"], ) - # Apply them. - for ext in self.distribution.ext_modules: - for key, value in extension_extra.items(): - setattr(ext, key, value) - - -class CleanCommand(clean): - - user_options = clean.user_options + [ - ('sources', None, - "remove Cython build output (C sources)")] - - boolean_options = clean.boolean_options + ['sources'] - - def initialize_options(self): - clean.initialize_options(self) - self.sources = None - - def run(self): - clean.run(self) - if self.sources: - if os.path.exists('src'): - remove_tree('src', dry_run=self.dry_run) - else: - log.info("'%s' does not exist -- can't clean it", 'src') - - -class CythonizeCommand(Command): - - user_options = [] - def initialize_options(self): - pass - def finalize_options(self): - pass - - def run(self): - - # Cythonize, if required. We do it individually since we must update - # the existing extension instead of replacing them all. - for i, ext in enumerate(self.distribution.ext_modules): - if any(s.endswith('.pyx') for s in ext.sources): - if is_msvc(): - ext.define_macros.append(('inline', '__inline')) - new_ext = cythonize( - ext, - compiler_directives=dict( - c_string_type='str', - c_string_encoding='ascii', - embedsignature=True, - language_level=2, - ), - build_dir='src', - include_path=ext.include_dirs, - )[0] - ext.sources = new_ext.sources - - -class BuildExtCommand(build_ext): - - if os.name != 'nt': - user_options = build_ext.user_options + [ - ('no-pkg-config', None, - "do not use pkg-config to configure dependencies")] - - boolean_options = build_ext.boolean_options + ['no-pkg-config'] - - def initialize_options(self): - build_ext.initialize_options(self) - self.no_pkg_config = None - else: - no_pkg_config = 1 - - def run(self): - - # Propagate build options to config - obj = self.distribution.get_command_obj('config') - obj.compiler = self.compiler - obj.no_pkg_config = self.no_pkg_config - obj.include_dirs = self.include_dirs - obj.libraries = self.libraries - obj.library_dirs = self.library_dirs - - self.run_command('config') - - # We write a header file containing everything we have discovered by - # inspecting the libraries which exist. This is the main mechanism we - # use to detect differenced between FFmpeg and Libav. - - include_dir = os.path.join(self.build_temp, 'include') - pyav_dir = os.path.join(include_dir, 'pyav') - try: - os.makedirs(pyav_dir) - except OSError as e: - if e.errno != errno.EEXIST: - raise - header_path = os.path.join(pyav_dir, 'config.h') - print('writing', header_path) - with open(header_path, 'w') as fh: - fh.write('#ifndef PYAV_COMPAT_H\n') - fh.write('#define PYAV_COMPAT_H\n') - for k, v in sorted(config_macros.items()): - fh.write('#define %s %s\n' % (k, v)) - fh.write('#endif\n') - - self.include_dirs = self.include_dirs or [] - self.include_dirs.append(include_dir) - # Propagate config to cythonize. - for i, ext in enumerate(self.distribution.ext_modules): - unique_extend(ext.include_dirs, self.include_dirs) - unique_extend(ext.library_dirs, self.library_dirs) - unique_extend(ext.libraries, self.libraries) - - self.run_command('cythonize') - build_ext.run(self) +package_folders = pathlib.Path(IMPORT_NAME).glob("**/") +package_data = { + ".".join(pckg.parts): ["*.pxd", "*.pyi", "*.typed"] for pckg in package_folders +} setup( - - name='av', - version=version, - description="Pythonic bindings for FFmpeg's libraries.", - - author="Mike Boers", - author_email="pyav@mikeboers.com", - - url="https://github.com/PyAV-Org/PyAV", - - packages=find_packages(exclude=['build*', 'examples*', 'scratchpad*', 'tests*']), - - zip_safe=False, + packages=find_packages(include=[f"{IMPORT_NAME}*"]), + package_data=package_data, ext_modules=ext_modules, - - cmdclass={ - 'build_ext': BuildExtCommand, - 'clean': CleanCommand, - 'config': ConfigCommand, - 'cythonize': CythonizeCommand, - }, - - test_suite='tests', - - entry_points={ - 'console_scripts': [ - 'pyav = av.__main__:main', - ], - }, - - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: POSIX', - 'Operating System :: Unix', - 'Operating System :: Microsoft :: Windows', - 'Programming Language :: Cython', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Multimedia :: Sound/Audio', - 'Topic :: Multimedia :: Sound/Audio :: Conversion', - 'Topic :: Multimedia :: Video', - 'Topic :: Multimedia :: Video :: Conversion', - ], - - distclass=distclass, - + options=options, ) diff --git a/tests/common.py b/tests/common.py index 2bb13d5f7..19984c77c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,28 +1,41 @@ -from __future__ import division +from __future__ import annotations -from unittest import TestCase as _Base import datetime import errno import functools import os -import sys -import types +import typing +from typing import TYPE_CHECKING +from unittest import TestCase as _Base -from av.datasets import fate as fate_suite +import numpy as np +from av.datasets import fate as fate_suite try: - import PIL.Image as Image - import PIL.ImageFilter as ImageFilter + import PIL # noqa + + has_pillow = True except ImportError: - Image = ImageFilter = None + has_pillow = False + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, TypeVar + + from PIL.Image import Image + + T = TypeVar("T") + + +__all__ = ("fate_suite",) -is_windows = os.name == 'nt' +is_windows = os.name == "nt" skip_tests = frozenset(os.environ.get("PYAV_SKIP_TESTS", "").split(",")) -def makedirs(path): +def safe_makedirs(path: str) -> None: try: os.makedirs(path) except OSError as e: @@ -33,125 +46,103 @@ def makedirs(path): _start_time = datetime.datetime.now() -def _sandbox(timed=False): - root = os.path.abspath(os.path.join( - __file__, '..', '..', - 'sandbox' - )) +def _sandbox(timed: bool = False) -> str: + root = os.path.abspath(os.path.join(__file__, "..", "..", "sandbox")) + + if timed: + sandbox = os.path.join(root, _start_time.strftime("%Y%m%d-%H%M%S")) + else: + sandbox = root - sandbox = os.path.join( - root, - _start_time.strftime('%Y%m%d-%H%M%S'), - ) if timed else root if not os.path.exists(sandbox): os.makedirs(sandbox) + return sandbox -def asset(*args): +def asset(*args: str) -> str: adir = os.path.dirname(__file__) - return os.path.abspath(os.path.join(adir, 'assets', *args)) + return os.path.abspath(os.path.join(adir, "assets", *args)) # Store all of the sample data here. -os.environ['PYAV_TESTDATA_DIR'] = asset() +os.environ["PYAV_TESTDATA_DIR"] = asset() + +def fate_png() -> str: + return fate_suite("png1/55c99e750a5fd6_50314226.png") -def fate_png(): - return fate_suite('png1/55c99e750a5fd6_50314226.png') +def sandboxed( + *args: str, makedirs: bool = True, sandbox: str | None = None, timed: bool = False +) -> str: + path = os.path.join(_sandbox(timed) if sandbox is None else sandbox, *args) + if makedirs: + safe_makedirs(os.path.dirname(path)) -def sandboxed(*args, **kwargs): - do_makedirs = kwargs.pop('makedirs', True) - base = kwargs.pop('sandbox', None) - timed = kwargs.pop('timed', False) - if kwargs: - raise TypeError('extra kwargs: %s' % ', '.join(sorted(kwargs))) - path = os.path.join(_sandbox(timed=timed) if base is None else base, *args) - if do_makedirs: - makedirs(os.path.dirname(path)) return path -class MethodLogger(object): +# Decorator for running a test in the sandbox directory +def run_in_sandbox(func: Callable[..., T]) -> Callable[..., T]: + @functools.wraps(func) + def _inner(self: Any, *args: Any, **kwargs: Any) -> T: + current_dir = os.getcwd() + try: + os.chdir(self.sandbox) + return func(self, *args, **kwargs) + finally: + os.chdir(current_dir) - def __init__(self, obj): - self._obj = obj - self._log = [] + return _inner - def __getattr__(self, name): - value = getattr(self._obj, name) - if isinstance(value, (types.MethodType, types.FunctionType, types.BuiltinFunctionType, types.BuiltinMethodType)): - return functools.partial(self._method, name, value) - else: - self._log.append(('__getattr__', (name, ), {})) - return value - def _method(self, name, meth, *args, **kwargs): - self._log.append((name, args, kwargs)) - return meth(*args, **kwargs) +def assertNdarraysEqual(a: np.ndarray, b: np.ndarray) -> None: + assert a.shape == b.shape - def _filter(self, type_): - return [log for log in self._log if log[0] == type_] + comparison = a == b + if not comparison.all(): + it = np.nditer(comparison, flags=["multi_index"]) + msg = "" + for equal in it: + if not equal: + msg += f"- arrays differ at index {it.multi_index}; {a[it.multi_index]} {b[it.multi_index]}\n" + assert False, f"ndarrays contents differ\n{msg}" -class TestCase(_Base): +@typing.no_type_check +def assertImagesAlmostEqual(a: Image, b: Image, epsilon: float = 0.1) -> None: + import PIL.ImageFilter as ImageFilter + + assert a.size == b.size + a = a.filter(ImageFilter.BLUR).getdata() + b = b.filter(ImageFilter.BLUR).getdata() + for i, ax, bx in zip(range(len(a)), a, b): + diff = sum(abs(ac / 256 - bc / 256) for ac, bc in zip(ax, bx)) / 3 + assert diff < epsilon, f"images differed by {diff} at index {i}; {ax} {bx}" + +class TestCase(_Base): @classmethod - def _sandbox(cls, timed=True): + def _sandbox(cls, timed: bool = True) -> str: path = os.path.join(_sandbox(timed=timed), cls.__name__) - makedirs(path) + safe_makedirs(path) return path @property - def sandbox(self): + def sandbox(self) -> str: return self._sandbox(timed=True) - def sandboxed(self, *args, **kwargs): - kwargs.setdefault('sandbox', self.sandbox) - kwargs.setdefault('timed', True) - return sandboxed(*args, **kwargs) - - def assertImagesAlmostEqual(self, a, b, epsilon=0.1, *args): - self.assertEqual(a.size, b.size, 'sizes dont match') - a = a.filter(ImageFilter.BLUR).getdata() - b = b.filter(ImageFilter.BLUR).getdata() - for i, ax, bx in zip(range(len(a)), a, b): - diff = sum(abs(ac / 256 - bc / 256) for ac, bc in zip(ax, bx)) / 3 - if diff > epsilon: - self.fail('images differed by %s at index %d; %s %s' % (diff, i, ax, bx)) - - # Add some of the unittest methods that we love from 2.7. - if sys.version_info < (2, 7): - - def assertIs(self, a, b, msg=None): - if a is not b: - self.fail(msg or '%r at 0x%x is not %r at 0x%x; %r is not %r' % (type(a), id(a), type(b), id(b), a, b)) - - def assertIsNot(self, a, b, msg=None): - if a is b: - self.fail(msg or 'both are %r at 0x%x; %r' % (type(a), id(a), a)) - - def assertIsNone(self, x, msg=None): - if x is not None: - self.fail(msg or 'is not None; %r' % x) - - def assertIsNotNone(self, x, msg=None): - if x is None: - self.fail(msg or 'is None; %r' % x) - - def assertIn(self, a, b, msg=None): - if a not in b: - self.fail(msg or '%r not in %r' % (a, b)) - - def assertNotIn(self, a, b, msg=None): - if a in b: - self.fail(msg or '%r in %r' % (a, b)) - - def assertIsInstance(self, instance, types, msg=None): - if not isinstance(instance, types): - self.fail(msg or 'not an instance of %r; %r' % (types, instance)) - - def assertNotIsInstance(self, instance, types, msg=None): - if isinstance(instance, types): - self.fail(msg or 'is an instance of %r; %r' % (types, instance)) + def sandboxed( + self, + *args: str, + makedirs: bool = True, + timed: bool = True, + sandbox: str | None = None, + ) -> str: + if sandbox is None: + return sandboxed( + *args, makedirs=makedirs, timed=timed, sandbox=self.sandbox + ) + else: + return sandboxed(*args, makedirs=makedirs, timed=timed, sandbox=sandbox) diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index f2a15b027..000000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -autopep8 -Cython -editorconfig -flake8 -isort -numpy -Pillow -sphinx diff --git a/tests/test_audiofifo.py b/tests/test_audiofifo.py index 8f67fbe72..85e9481ee 100644 --- a/tests/test_audiofifo.py +++ b/tests/test_audiofifo.py @@ -1,13 +1,13 @@ +from fractions import Fraction + import av from .common import TestCase, fate_suite class TestAudioFifo(TestCase): - - def test_data(self): - - container = av.open(fate_suite('audio-reference/chorusnoise_2ch_44kHz_s16.wav')) + def test_data(self) -> None: + container = av.open(fate_suite("audio-reference/chorusnoise_2ch_44kHz_s16.wav")) stream = container.streams.audio[0] fifo = av.AudioFifo() @@ -15,60 +15,66 @@ def test_data(self): input_ = [] output = [] - for i, packet in enumerate(container.demux(stream)): - for frame in packet.decode(): - input_.append(frame.planes[0].to_bytes()) - fifo.write(frame) - for frame in fifo.read_many(512, partial=i == 10): - output.append(frame.planes[0].to_bytes()) + for i, frame in enumerate(container.decode(stream)): + input_.append(bytes(frame.planes[0])) + fifo.write(frame) + for frame in fifo.read_many(512, partial=i == 10): + output.append(bytes(frame.planes[0])) if i == 10: break - input_ = b''.join(input_) - output = b''.join(output) - min_len = min(len(input_), len(output)) + input_bytes = b"".join(input_) + output_bytes = b"".join(output) + min_len = min(len(input_bytes), len(output_bytes)) - self.assertTrue(min_len > 10 * 512 * 2 * 2) - self.assertTrue(input_[:min_len] == output[:min_len]) - - def test_pts_simple(self): + assert min_len > 10 * 512 * 2 * 2 + assert input_bytes[:min_len] == output_bytes[:min_len] + def test_pts_simple(self) -> None: fifo = av.AudioFifo() + assert str(fifo).startswith( + " at 0x" + ) + oframe = fifo.read(512) - self.assertTrue(oframe is not None) - self.assertEqual(oframe.pts, 0) - self.assertEqual(oframe.time_base, iframe.time_base) + assert oframe is not None + assert oframe.pts == 0 + assert oframe.time_base == iframe.time_base - self.assertEqual(fifo.samples_written, 1024) - self.assertEqual(fifo.samples_read, 512) - self.assertEqual(fifo.pts_per_sample, 1.0) + assert fifo.samples_written == 1024 + assert fifo.samples_read == 512 + assert fifo.pts_per_sample == 1.0 iframe.pts = 1024 fifo.write(iframe) oframe = fifo.read(512) - self.assertTrue(oframe is not None) - self.assertEqual(oframe.pts, 512) - self.assertEqual(oframe.time_base, iframe.time_base) + assert oframe is not None + + assert oframe.pts == 512 + assert oframe.time_base == iframe.time_base iframe.pts = 9999 # Wrong! self.assertRaises(ValueError, fifo.write, iframe) - def test_pts_complex(self): - + def test_pts_complex(self) -> None: fifo = av.AudioFifo() iframe = av.AudioFrame(samples=1024) iframe.pts = 0 iframe.sample_rate = 48000 - iframe.time_base = '1/96000' + iframe.time_base = Fraction("1/96000") fifo.write(iframe) iframe.pts = 2048 @@ -76,28 +82,26 @@ def test_pts_complex(self): oframe = fifo.read_many(1024)[-1] - self.assertEqual(oframe.pts, 2048) - self.assertEqual(fifo.pts_per_sample, 2.0) - - def test_missing_sample_rate(self): + assert oframe.pts == 2048 + assert fifo.pts_per_sample == 2.0 + def test_missing_sample_rate(self) -> None: fifo = av.AudioFifo() iframe = av.AudioFrame(samples=1024) iframe.pts = 0 - iframe.time_base = '1/48000' + iframe.time_base = Fraction("1/48000") fifo.write(iframe) oframe = fifo.read(512) - self.assertTrue(oframe is not None) - self.assertIsNone(oframe.pts) - self.assertEqual(oframe.sample_rate, 0) - self.assertEqual(oframe.time_base, iframe.time_base) - - def test_missing_time_base(self): + assert oframe is not None + assert oframe.pts is None + assert oframe.sample_rate == 0 + assert oframe.time_base == iframe.time_base + def test_missing_time_base(self) -> None: fifo = av.AudioFifo() iframe = av.AudioFrame(samples=1024) @@ -108,7 +112,6 @@ def test_missing_time_base(self): oframe = fifo.read(512) - self.assertTrue(oframe is not None) - self.assertIsNone(oframe.pts) - self.assertIsNone(oframe.time_base) - self.assertEqual(oframe.sample_rate, iframe.sample_rate) + assert oframe is not None + assert oframe.pts is None and oframe.time_base is None + assert oframe.sample_rate == iframe.sample_rate diff --git a/tests/test_audioformat.py b/tests/test_audioformat.py index ed87496fe..244d3ad7d 100644 --- a/tests/test_audioformat.py +++ b/tests/test_audioformat.py @@ -1,29 +1,28 @@ import sys -from av import AudioFormat +import pytest -from .common import TestCase +from av import AudioFormat -postfix = 'le' if sys.byteorder == 'little' else 'be' +def test_s16_inspection() -> None: + fmt = AudioFormat("s16") + postfix = "le" if sys.byteorder == "little" else "be" + assert fmt.name == "s16" + assert not fmt.is_planar + assert fmt.bits == 16 + assert fmt.bytes == 2 + assert fmt.container_name == "s16" + postfix + assert fmt.planar.name == "s16p" + assert fmt.packed is fmt -class TestAudioFormats(TestCase): - def test_s16_inspection(self): - fmt = AudioFormat('s16') - self.assertEqual(fmt.name, 's16') - self.assertFalse(fmt.is_planar) - self.assertEqual(fmt.bits, 16) - self.assertEqual(fmt.bytes, 2) - self.assertEqual(fmt.container_name, 's16' + postfix) - self.assertEqual(fmt.planar.name, 's16p') - self.assertIs(fmt.packed, fmt) +def test_s32p_inspection() -> None: + fmt = AudioFormat("s32p") + assert fmt.name == "s32p" + assert fmt.is_planar + assert fmt.bits == 32 + assert fmt.bytes == 4 - def test_s32p_inspection(self): - fmt = AudioFormat('s32p') - self.assertEqual(fmt.name, 's32p') - self.assertTrue(fmt.is_planar) - self.assertEqual(fmt.bits, 32) - self.assertEqual(fmt.bytes, 4) - self.assertRaises(ValueError, lambda: fmt.container_name) + pytest.raises(ValueError, lambda: fmt.container_name) diff --git a/tests/test_audioframe.py b/tests/test_audioframe.py index eb3490fe5..c6bf87b3f 100644 --- a/tests/test_audioframe.py +++ b/tests/test_audioframe.py @@ -1,183 +1,203 @@ -import warnings +from re import escape -import numpy +import numpy as np +import pytest from av import AudioFrame -from av.deprecation import AttributeRenamedWarning - -from .common import TestCase - - -class TestAudioFrameConstructors(TestCase): - - def test_null_constructor(self): - frame = AudioFrame() - self.assertEqual(frame.format.name, 's16') - self.assertEqual(frame.layout.name, 'stereo') - self.assertEqual(len(frame.planes), 0) - self.assertEqual(frame.samples, 0) - - def test_manual_flt_mono_constructor(self): - frame = AudioFrame(format='flt', layout='mono', samples=160) - self.assertEqual(frame.format.name, 'flt') - self.assertEqual(frame.layout.name, 'mono') - self.assertEqual(len(frame.planes), 1) - self.assertEqual(frame.planes[0].buffer_size, 640) - self.assertEqual(frame.samples, 160) - - def test_manual_flt_stereo_constructor(self): - frame = AudioFrame(format='flt', layout='stereo', samples=160) - self.assertEqual(frame.format.name, 'flt') - self.assertEqual(frame.layout.name, 'stereo') - self.assertEqual(len(frame.planes), 1) - self.assertEqual(frame.planes[0].buffer_size, 1280) - self.assertEqual(frame.samples, 160) - - def test_manual_fltp_stereo_constructor(self): - frame = AudioFrame(format='fltp', layout='stereo', samples=160) - self.assertEqual(frame.format.name, 'fltp') - self.assertEqual(frame.layout.name, 'stereo') - self.assertEqual(len(frame.planes), 2) - self.assertEqual(frame.planes[0].buffer_size, 640) - self.assertEqual(frame.planes[1].buffer_size, 640) - self.assertEqual(frame.samples, 160) - - def test_manual_s16_mono_constructor(self): - frame = AudioFrame(format='s16', layout='mono', samples=160) - self.assertEqual(frame.format.name, 's16') - self.assertEqual(frame.layout.name, 'mono') - self.assertEqual(len(frame.planes), 1) - self.assertEqual(frame.planes[0].buffer_size, 320) - self.assertEqual(frame.samples, 160) - - def test_manual_s16_mono_constructor_align_8(self): - frame = AudioFrame(format='s16', layout='mono', samples=159, align=8) - self.assertEqual(frame.format.name, 's16') - self.assertEqual(frame.layout.name, 'mono') - self.assertEqual(len(frame.planes), 1) - self.assertEqual(frame.planes[0].buffer_size, 320) - self.assertEqual(frame.samples, 159) - - def test_manual_s16_stereo_constructor(self): - frame = AudioFrame(format='s16', layout='stereo', samples=160) - self.assertEqual(frame.format.name, 's16') - self.assertEqual(frame.layout.name, 'stereo') - self.assertEqual(len(frame.planes), 1) - self.assertEqual(frame.planes[0].buffer_size, 640) - self.assertEqual(frame.samples, 160) - - def test_manual_s16p_stereo_constructor(self): - frame = AudioFrame(format='s16p', layout='stereo', samples=160) - self.assertEqual(frame.format.name, 's16p') - self.assertEqual(frame.layout.name, 'stereo') - self.assertEqual(len(frame.planes), 2) - self.assertEqual(frame.planes[0].buffer_size, 320) - self.assertEqual(frame.planes[1].buffer_size, 320) - self.assertEqual(frame.samples, 160) - - -class TestAudioFrameConveniences(TestCase): - - def test_basic_to_ndarray(self): - frame = AudioFrame(format='s16p', layout='stereo', samples=160) - array = frame.to_ndarray() - self.assertEqual(array.dtype, ' None: + frame = AudioFrame() + assert frame.format.name == "s16" + assert frame.layout.name == "stereo" + assert len(frame.planes) == 0 + assert frame.samples == 0 + + +def test_manual_flt_mono_constructor() -> None: + frame = AudioFrame(format="flt", layout="mono", samples=160) + assert frame.format.name == "flt" + assert frame.layout.name == "mono" + assert len(frame.planes) == 1 + assert frame.planes[0].buffer_size == 640 + assert frame.samples == 160 + + +def test_manual_flt_stereo_constructor() -> None: + frame = AudioFrame(format="flt", layout="stereo", samples=160) + assert frame.format.name == "flt" + assert frame.layout.name == "stereo" + assert len(frame.planes) == 1 + assert frame.planes[0].buffer_size == 1280 + assert frame.samples == 160 + + +def test_manual_fltp_stereo_constructor() -> None: + frame = AudioFrame(format="fltp", layout="stereo", samples=160) + assert frame.format.name == "fltp" + assert frame.layout.name == "stereo" + assert len(frame.planes) == 2 + assert frame.planes[0].buffer_size == 640 + assert frame.planes[1].buffer_size == 640 + assert frame.samples == 160 + + +def test_manual_s16_mono_constructor() -> None: + frame = AudioFrame(format="s16", layout="mono", samples=160) + assert frame.format.name == "s16" + assert frame.layout.name == "mono" + assert len(frame.planes) == 1 + assert frame.planes[0].buffer_size == 320 + assert frame.samples == 160 + + +def test_manual_s16_mono_constructor_align_8() -> None: + frame = AudioFrame(format="s16", layout="mono", samples=159, align=8) + assert frame.format.name == "s16" + assert frame.layout.name == "mono" + assert len(frame.planes) == 1 + assert frame.planes[0].buffer_size == 320 + assert frame.samples == 159 + + +def test_manual_s16_stereo_constructor() -> None: + frame = AudioFrame(format="s16", layout="stereo", samples=160) + assert frame.format.name == "s16" + assert frame.layout.name == "stereo" + assert len(frame.planes) == 1 + assert frame.planes[0].buffer_size == 640 + assert frame.samples == 160 + + +def test_manual_s16p_stereo_constructor() -> None: + frame = AudioFrame(format="s16p", layout="stereo", samples=160) + assert frame.format.name == "s16p" + assert frame.layout.name == "stereo" + assert len(frame.planes) == 2 + assert frame.planes[0].buffer_size == 320 + assert frame.planes[1].buffer_size == 320 + assert frame.samples == 160 + + +def test_basic_to_ndarray() -> None: + frame = AudioFrame(format="s16p", layout="stereo", samples=160) + array = frame.to_ndarray() + assert array.dtype == "i2" + assert array.shape == (2, 160) + + +def test_ndarray_dbl() -> None: + layouts = [ + ("dbl", "mono", (1, 160)), + ("dbl", "stereo", (1, 320)), + ("dblp", "mono", (1, 160)), + ("dblp", "stereo", (2, 160)), + ] + for format, layout, size in layouts: + array = np.zeros(shape=size, dtype="f8") + for i in range(size[0]): + array[i][:] = np.random.rand(size[1]) + frame = AudioFrame.from_ndarray(array, format=format, layout=layout) + assert frame.format.name == format + assert frame.layout.name == layout + assert frame.samples == 160 + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_from_ndarray_value_error() -> None: + # incorrect dtype + array = np.zeros(shape=(1, 160), dtype="f2") + with pytest.raises( + ValueError, match="Expected numpy array with dtype `float32` but got `float16`" + ) as cm: + AudioFrame.from_ndarray(array, format="flt", layout="mono") + + # incorrect number of dimensions + array2 = np.zeros(shape=(1, 160, 2), dtype="f4") + with pytest.raises( + ValueError, match="Expected numpy array with ndim `2` but got `3`" + ) as cm: + AudioFrame.from_ndarray(array2, format="flt", layout="mono") + + # incorrect shape + array = np.zeros(shape=(2, 160), dtype="f4") + with pytest.raises( + ValueError, + match=escape("Expected packed `array.shape[0]` to equal `1` but got `2`"), + ) as cm: + AudioFrame.from_ndarray(array, format="flt", layout="mono") + + +def test_ndarray_flt() -> None: + layouts = [ + ("flt", "mono", (1, 160)), + ("flt", "stereo", (1, 320)), + ("fltp", "mono", (1, 160)), + ("fltp", "stereo", (2, 160)), + ] + for format, layout, size in layouts: + array: np.ndarray = np.zeros(shape=size, dtype="f4") + for i in range(size[0]): + array[i][:] = np.random.rand(size[1]) + frame = AudioFrame.from_ndarray(array, format=format, layout=layout) + assert frame.format.name == format + assert frame.layout.name == layout + assert frame.samples == 160 + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_s16() -> None: + layouts = [ + ("s16", "mono", (1, 160)), + ("s16", "stereo", (1, 320)), + ("s16p", "mono", (1, 160)), + ("s16p", "stereo", (2, 160)), + ] + for format, layout, size in layouts: + array = np.random.randint(0, 256, size=size, dtype="i2") + frame = AudioFrame.from_ndarray(array, format=format, layout=layout) + assert frame.format.name == format + assert frame.layout.name == layout + assert frame.samples == 160 + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_s16p_align_8() -> None: + frame = AudioFrame(format="s16p", layout="stereo", samples=159, align=8) + array = frame.to_ndarray() + assert array.dtype == "i2" + assert array.shape == (2, 159) + + +def test_ndarray_s32() -> None: + layouts = [ + ("s32", "mono", (1, 160)), + ("s32", "stereo", (1, 320)), + ("s32p", "mono", (1, 160)), + ("s32p", "stereo", (2, 160)), + ] + for format, layout, size in layouts: + array = np.random.randint(0, 256, size=size, dtype="i4") + frame = AudioFrame.from_ndarray(array, format=format, layout=layout) + assert frame.format.name == format + assert frame.layout.name == layout + assert frame.samples == 160 + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_u8() -> None: + layouts = [ + ("u8", "mono", (1, 160)), + ("u8", "stereo", (1, 320)), + ("u8p", "mono", (1, 160)), + ("u8p", "stereo", (2, 160)), + ] + for format, layout, size in layouts: + array = np.random.randint(0, 256, size=size, dtype="u1") + frame = AudioFrame.from_ndarray(array, format=format, layout=layout) + assert frame.format.name == format + assert frame.layout.name == layout + assert frame.samples == 160 + assertNdarraysEqual(frame.to_ndarray(), array) diff --git a/tests/test_audiolayout.py b/tests/test_audiolayout.py index 966bd4894..35ba4c160 100644 --- a/tests/test_audiolayout.py +++ b/tests/test_audiolayout.py @@ -1,44 +1,26 @@ from av import AudioLayout -from .common import TestCase - - -class TestAudioLayout(TestCase): - - def test_stereo_properties(self): - layout = AudioLayout('stereo') - self._test_stereo(layout) - - def test_2channel_properties(self): - layout = AudioLayout(2) - self._test_stereo(layout) - - def test_channel_counts(self): - self.assertRaises(ValueError, AudioLayout, -1) - self.assertRaises(ValueError, AudioLayout, 9) - - def _test_stereo(self, layout): - self.assertEqual(layout.name, 'stereo') - self.assertEqual(len(layout.channels), 2) - self.assertEqual(repr(layout), "") - self.assertEqual(layout.channels[0].name, "FL") - self.assertEqual(layout.channels[0].description, "front left") - self.assertEqual(repr(layout.channels[0]), "") - self.assertEqual(layout.channels[1].name, "FR") - self.assertEqual(layout.channels[1].description, "front right") - self.assertEqual(repr(layout.channels[1]), "") - - def test_defaults(self): - for i, name in enumerate(''' - mono - stereo - 2.1 - 4.0 - 5.0 - 5.1 - 6.1 - 7.1 - '''.strip().split()): - layout = AudioLayout(i + 1) - self.assertEqual(layout.name, name) - self.assertEqual(len(layout.channels), i + 1) + +def _test_stereo(layout: AudioLayout) -> None: + assert layout.name == "stereo" + assert layout.nb_channels == 2 + assert repr(layout) == "" + + # Re-enable when FFmpeg 6.0 is dropped. + + # assert layout.channels[0].name == "FL" + # assert layout.channels[0].description == "front left" + # assert repr(layout.channels[0]) == "" + # assert layout.channels[1].name == "FR" + # assert layout.channels[1].description == "front right" + # assert repr(layout.channels[1]) == "" + + +def test_stereo_from_str() -> None: + layout = AudioLayout("stereo") + _test_stereo(layout) + + +def test_stereo_from_layout() -> None: + layout2 = AudioLayout(AudioLayout("stereo")) + _test_stereo(layout2) diff --git a/tests/test_audioresampler.py b/tests/test_audioresampler.py index 683d70376..65fdfdddb 100644 --- a/tests/test_audioresampler.py +++ b/tests/test_audioresampler.py @@ -1,103 +1,334 @@ +from fractions import Fraction + +import pytest + +import av from av import AudioFrame, AudioResampler -from .common import TestCase + +def test_flush_immediately() -> None: + """ + If we flush the resampler before passing any input, it returns + a `None` frame without setting up the graph. + """ + + resampler = AudioResampler() + + # flush + oframes = resampler.resample(None) + assert len(oframes) == 0 -class TestAudioResampler(TestCase): +def test_identity_passthrough() -> None: + """ + If we don't ask it to do anything, it won't. + """ - def test_identity_passthrough(self): + resampler = AudioResampler() - # If we don't ask it to do anything, it won't. + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) - resampler = AudioResampler() + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + assert iframe is oframes[0] - iframe = AudioFrame('s16', 'stereo', 1024) - oframe = resampler.resample(iframe) + # resample another frame + iframe.pts = 1024 - self.assertIs(iframe, oframe) + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + assert iframe is oframes[0] - def test_matching_passthrough(self): + # flush + oframes = resampler.resample(None) + assert len(oframes) == 0 - # If the frames match, it won't do anything. - resampler = AudioResampler('s16', 'stereo') +def test_matching_passthrough() -> None: + """ + If the frames match, it won't do anything. + """ - iframe = AudioFrame('s16', 'stereo', 1024) - oframe = resampler.resample(iframe) + resampler = AudioResampler("s16", "stereo") - self.assertIs(iframe, oframe) + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) - def test_pts_assertion_same_rate(self): + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + assert iframe is oframes[0] - resampler = AudioResampler('s16', 'mono') + # resample another frame + iframe.pts = 1024 - iframe = AudioFrame('s16', 'stereo', 1024) - iframe.sample_rate = 48000 - iframe.time_base = '1/48000' - iframe.pts = 0 + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + assert iframe is oframes[0] - oframe = resampler.resample(iframe) + # flush + oframes = resampler.resample(None) + assert len(oframes) == 0 - self.assertEqual(oframe.pts, 0) - self.assertEqual(oframe.time_base, iframe.time_base) - self.assertEqual(oframe.sample_rate, iframe.sample_rate) - iframe.pts = 1024 - oframe = resampler.resample(iframe) +def test_pts_assertion_same_rate() -> None: + av.logging.set_level(av.logging.VERBOSE) + resampler = AudioResampler("s16", "mono") - self.assertEqual(oframe.pts, 1024) - self.assertEqual(oframe.time_base, iframe.time_base) - self.assertEqual(oframe.sample_rate, iframe.sample_rate) + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + iframe.time_base = Fraction(1, 48000) + iframe.pts = 0 - iframe.pts = 9999 - self.assertRaises(ValueError, resampler.resample, iframe) + oframes = resampler.resample(iframe) + assert len(oframes) == 1 - def test_pts_assertion_new_rate(self): + oframe = oframes[0] + assert oframe.pts == 0 + assert oframe.time_base == iframe.time_base + assert oframe.sample_rate == iframe.sample_rate + assert oframe.samples == iframe.samples - resampler = AudioResampler('s16', 'mono', 44100) + # resample another frame + iframe.pts = 1024 - iframe = AudioFrame('s16', 'stereo', 1024) - iframe.sample_rate = 48000 - iframe.time_base = '1/48000' - iframe.pts = 0 + oframes = resampler.resample(iframe) + assert len(oframes) == 1 - oframe = resampler.resample(iframe) - self.assertEqual(oframe.pts, 0) - self.assertEqual(str(oframe.time_base), '1/44100') - self.assertEqual(oframe.sample_rate, 44100) + oframe = oframes[0] + assert oframe.pts == 1024 + assert oframe.time_base == iframe.time_base + assert oframe.sample_rate == iframe.sample_rate + assert oframe.samples == iframe.samples - samples_out = resampler.samples_out - self.assertTrue(samples_out > 0) + # resample another frame with a pts gap, do not raise exception + iframe.pts = 9999 + oframes = resampler.resample(iframe) + assert len(oframes) == 1 - iframe.pts = 1024 - oframe = resampler.resample(iframe) - self.assertEqual(oframe.pts, samples_out) - self.assertEqual(str(oframe.time_base), '1/44100') - self.assertEqual(oframe.sample_rate, 44100) + oframe = oframes[0] + assert oframe.pts == 9999 + assert oframe.time_base == iframe.time_base + assert oframe.sample_rate == iframe.sample_rate + assert oframe.samples == iframe.samples - def test_pts_missing_time_base(self): + # flush + oframes = resampler.resample(None) + assert len(oframes) == 0 + av.logging.set_level(None) - resampler = AudioResampler('s16', 'mono', 44100) - iframe = AudioFrame('s16', 'stereo', 1024) - iframe.sample_rate = 48000 - iframe.pts = 0 +def test_pts_assertion_new_rate_up() -> None: + resampler = AudioResampler("s16", "mono", 44100) - oframe = resampler.resample(iframe) - self.assertIs(oframe.pts, None) - self.assertIs(oframe.time_base, None) - self.assertEqual(oframe.sample_rate, 44100) + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + iframe.time_base = Fraction(1, 48000) + iframe.pts = 0 - def test_pts_complex_time_base(self): + oframes = resampler.resample(iframe) + assert len(oframes) == 1 - resampler = AudioResampler('s16', 'mono', 44100) + oframe = oframes[0] + assert oframe.pts == 0 + assert oframe.time_base == Fraction(1, 44100) + assert oframe.sample_rate == 44100 + assert oframe.samples == 925 + + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + iframe.time_base = Fraction(1, 48000) + iframe.pts = 1024 - iframe = AudioFrame('s16', 'stereo', 1024) - iframe.sample_rate = 48000 - iframe.time_base = '1/96000' - iframe.pts = 0 + oframes = resampler.resample(iframe) + assert len(oframes) == 1 - oframe = resampler.resample(iframe) - self.assertIs(oframe.pts, None) - self.assertIs(oframe.time_base, None) - self.assertEqual(oframe.sample_rate, 44100) + oframe = oframes[0] + assert oframe.pts == 925 + assert oframe.time_base == Fraction(1, 44100) + assert oframe.sample_rate == 44100 + assert oframe.samples == 941 + + # flush + oframes = resampler.resample(None) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 941 + 925 + assert oframe.time_base == Fraction(1, 44100) + assert oframe.sample_rate == 44100 + assert oframe.samples == 15 + + +def test_pts_assertion_new_rate_down() -> None: + resampler = AudioResampler("s16", "mono", 48000) + + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 44100 + iframe.time_base = Fraction(1, 44100) + iframe.pts = 0 + + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 0 + assert oframe.time_base == Fraction(1, 48000) + assert oframe.sample_rate == 48000 + assert oframe.samples == 1098 + + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 44100 + iframe.time_base = Fraction(1, 44100) + iframe.pts = 1024 + + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 1098 + assert oframe.time_base == Fraction(1, 48000) + assert oframe.sample_rate == 48000 + assert oframe.samples == 1114 + + # flush + oframes = resampler.resample(None) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 1114 + 1098 + assert oframe.time_base == Fraction(1, 48000) + assert oframe.sample_rate == 48000 + assert oframe.samples == 18 + + +def test_pts_assertion_new_rate_fltp() -> None: + resampler = AudioResampler("fltp", "mono", 8000, 1024) + + # resample one frame + iframe = AudioFrame("s16", "mono", 1024) + iframe.sample_rate = 8000 + iframe.time_base = Fraction(1, 1000) + iframe.pts = 0 + + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 0 + assert oframe.time_base == Fraction(1, 8000) + assert oframe.sample_rate == 8000 + assert oframe.samples == 1024 + + iframe = AudioFrame("s16", "mono", 1024) + iframe.sample_rate = 8000 + iframe.time_base = Fraction(1, 1000) + iframe.pts = 8192 + + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 65536 + assert oframe.time_base == Fraction(1, 8000) + assert oframe.sample_rate == 8000 + assert oframe.samples == 1024 + + # flush + oframes = resampler.resample(None) + assert len(oframes) == 0 + + +def test_pts_missing_time_base() -> None: + resampler = AudioResampler("s16", "mono", 44100) + + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + iframe.pts = 0 + + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 0 + assert oframe.time_base == Fraction(1, 44100) + assert oframe.sample_rate == 44100 + + # flush + oframes = resampler.resample(None) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.pts == 925 + assert oframe.time_base == Fraction(1, 44100) + assert oframe.sample_rate == 44100 + assert oframe.samples == 16 + + +def test_swr_options() -> None: + """ + libswresample options are passed through to the underlying aresample filter. + """ + resampler = AudioResampler( + "fltp", + "mono", + 16000, + options={"filter_size": "32", "phase_shift": "12", "cutoff": "0.95"}, + ) + assert resampler.options == { + "filter_size": "32", + "phase_shift": "12", + "cutoff": "0.95", + } + + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + iframe.time_base = Fraction(1, 48000) + iframe.pts = 0 + + oframes = resampler.resample(iframe) + assert len(oframes) == 1 + + oframe = oframes[0] + assert oframe.sample_rate == 16000 + assert oframe.format.name == "fltp" + assert oframe.layout.name == "mono" + + +def test_swr_options_invalid() -> None: + """ + An unknown option is reported rather than silently ignored. + """ + resampler = AudioResampler("s16", "mono", 44100, options={"not_a_real_option": "1"}) + + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + iframe.time_base = Fraction(1, 48000) + iframe.pts = 0 + + with pytest.raises(ValueError, match="unused config: not_a_real_option"): + resampler.resample(iframe) + + +def test_mismatched_input() -> None: + """ + Consecutive frames must have the same layout, sample format and sample rate. + """ + resampler = AudioResampler("s16", "mono", 44100) + + # resample one frame + iframe = AudioFrame("s16", "stereo", 1024) + iframe.sample_rate = 48000 + resampler.resample(iframe) + + # resample another frame with a sample format + iframe = AudioFrame("s16", "mono", 1024) + iframe.sample_rate = 48000 + with pytest.raises( + ValueError, match="Frame does not match AudioResampler setup." + ) as cm: + resampler.resample(iframe) diff --git a/tests/test_bitstream.py b/tests/test_bitstream.py new file mode 100644 index 000000000..820704e25 --- /dev/null +++ b/tests/test_bitstream.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import pytest + +import av +from av import Packet +from av.bitstream import BitStreamFilterContext, bitstream_filters_available + +from .common import fate_suite + + +def is_annexb(packet: Packet | bytes | None) -> bool: + if packet is None: + return False + + data = bytes(packet) + return data[:3] == b"\0\0\x01" or data[:4] == b"\0\0\0\x01" + + +def test_filters_available() -> None: + assert "h264_mp4toannexb" in bitstream_filters_available + + +def test_filter_chomp() -> None: + ctx = BitStreamFilterContext("chomp") + + src_packets: tuple[Packet, None] = (Packet(b"\x0012345\0\0\0"), None) + assert bytes(src_packets[0]) == b"\x0012345\0\0\0" + + result_packets = [] + for p in src_packets: + result_packets.extend(ctx.filter(p)) + + assert len(result_packets) == 1 + assert bytes(result_packets[0]) == b"\x0012345" + + +def test_filter_setts() -> None: + ctx = BitStreamFilterContext("setts=pts=N") + + ctx2 = BitStreamFilterContext(b"setts=pts=N") + del ctx2 + + p1 = Packet(b"\0") + p1.pts = 42 + p2 = Packet(b"\0") + p2.pts = 50 + src_packets = [p1, p2, None] + + result_packets: list[Packet] = [] + for p in src_packets: + result_packets.extend(ctx.filter(p)) + + assert len(result_packets) == 2 + assert result_packets[0].pts == 0 + assert result_packets[1].pts == 1 + + +def test_filter_h264_mp4toannexb() -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4"), "r") as container: + stream = container.streams.video[0] + ctx = BitStreamFilterContext("h264_mp4toannexb", stream) + + res_packets = [] + for p in container.demux(stream): + assert not is_annexb(p) + res_packets.extend(ctx.filter(p)) + + assert len(res_packets) == stream.frames + + for p in res_packets: + assert is_annexb(p) + + +def test_filter_in_stream_codec_and_name() -> None: + # A Codec or codec-name str can stand in for a Stream to pin the input codec, + # which is all a codec-specific filter needs to initialize. + # (Filters that also need the stream's extradata, such as h264_mp4toannexb, + # still require a full Stream to convert correctly.) + for in_stream in ("h264", av.Codec("h264", "r")): + ctx = BitStreamFilterContext("h264_mp4toannexb", in_stream) + assert isinstance(ctx, BitStreamFilterContext) + + +def test_filter_in_stream_wrong_codec() -> None: + # h264_mp4toannexb only supports h264, so a mismatched codec is rejected. + with pytest.raises(av.ArgumentError): + BitStreamFilterContext("h264_mp4toannexb", "hevc") + + +def test_filter_output_parameters() -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4"), "r") as container: + stream = container.streams.video[0] + + assert not is_annexb(stream.codec_context.extradata) + ctx = BitStreamFilterContext("h264_mp4toannexb", stream) + assert not is_annexb(stream.codec_context.extradata) + del ctx + + _ = BitStreamFilterContext("h264_mp4toannexb", stream, out_stream=stream) + assert is_annexb(stream.codec_context.extradata) + + +def test_filter_flush() -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4"), "r") as container: + stream = container.streams.video[0] + ctx = BitStreamFilterContext("h264_mp4toannexb", stream) + + res_packets = [] + for p in container.demux(stream): + res_packets.extend(ctx.filter(p)) + assert len(res_packets) == stream.frames + + container.seek(0) + # Without flushing, we expect to get an error: "A non-NULL packet sent after an EOF." + with pytest.raises(av.ArgumentError): + for p in container.demux(stream): + ctx.filter(p) + + ctx.flush() + container.seek(0) + for p in container.demux(stream): + res_packets.extend(ctx.filter(p)) + + assert len(res_packets) == stream.frames * 2 diff --git a/tests/test_chapters.py b/tests/test_chapters.py new file mode 100644 index 000000000..21272f527 --- /dev/null +++ b/tests/test_chapters.py @@ -0,0 +1,66 @@ +from fractions import Fraction + +import av + +from .common import fate_suite + + +def test_chapters() -> None: + expected = [ + { + "id": 1, + "start": 0, + "end": 5000, + "time_base": Fraction(1, 1000), + "metadata": {"title": "start"}, + }, + { + "id": 2, + "start": 5000, + "end": 10500, + "time_base": Fraction(1, 1000), + "metadata": {"title": "Five Seconds"}, + }, + { + "id": 3, + "start": 10500, + "end": 15000, + "time_base": Fraction(1, 1000), + "metadata": {"title": "Ten point 5 seconds"}, + }, + { + "id": 4, + "start": 15000, + "end": 19849, + "time_base": Fraction(1, 1000), + "metadata": {"title": "15 sec - over soon"}, + }, + ] + path = fate_suite("vorbis/vorbis_chapter_extension_demo.ogg") + with av.open(path) as container: + assert container.chapters() == expected + + +def test_set_chapters() -> None: + chapters: list[av.container.Chapter] = [ + { + "id": 1, + "start": 0, + "end": 5000, + "time_base": Fraction(1, 1000), + "metadata": {"title": "start"}, + } + ] + + path = fate_suite("h264/interlaced_crop.mp4") + with av.open(path) as container: + container.set_chapters(chapters) + assert container.chapters() == chapters + + +def test_set_chapters_empty() -> None: + path = fate_suite("vorbis/vorbis_chapter_extension_demo.ogg") + with av.open(path) as container: + assert len(container.chapters()) == 4 + container.set_chapters([]) + assert container.chapters() == [] diff --git a/tests/test_codec.py b/tests/test_codec.py index a5026864b..2f8b95e0b 100644 --- a/tests/test_codec.py +++ b/tests/test_codec.py @@ -1,107 +1,167 @@ -import unittest +import pytest from av import AudioFormat, Codec, VideoFormat, codecs_available +from av.codec import PixFmtLoss, find_best_pix_fmt_of_list from av.codec.codec import UnknownCodecError -from .common import TestCase - - -# some older ffmpeg versions have no native opus encoder -try: - opus_c = Codec('opus', 'w') - opus_encoder_missing = False -except UnknownCodecError: - opus_encoder_missing = True - - -class TestCodecs(TestCase): - def test_codec_bogus(self): - with self.assertRaises(UnknownCodecError): - Codec('bogus123') - with self.assertRaises(UnknownCodecError): - Codec('bogus123', 'w') - - def test_codec_mpeg4_decoder(self): - c = Codec('mpeg4') - - self.assertEqual(c.name, 'mpeg4') - self.assertEqual(c.long_name, 'MPEG-4 part 2') - self.assertEqual(c.type, 'video') - self.assertIn(c.id, (12, 13)) - self.assertTrue(c.is_decoder) - self.assertFalse(c.is_encoder) - - # audio - self.assertIsNone(c.audio_formats) - self.assertIsNone(c.audio_rates) - - # video - formats = c.video_formats - self.assertTrue(formats) - self.assertIsInstance(formats[0], VideoFormat) - self.assertTrue(any(f.name == 'yuv420p' for f in formats)) - - self.assertIsNone(c.frame_rates) - - def test_codec_mpeg4_encoder(self): - c = Codec('mpeg4', 'w') - self.assertEqual(c.name, 'mpeg4') - self.assertEqual(c.long_name, 'MPEG-4 part 2') - self.assertEqual(c.type, 'video') - self.assertIn(c.id, (12, 13)) - self.assertTrue(c.is_encoder) - self.assertFalse(c.is_decoder) - - # audio - self.assertIsNone(c.audio_formats) - self.assertIsNone(c.audio_rates) - - # video - formats = c.video_formats - self.assertTrue(formats) - self.assertIsInstance(formats[0], VideoFormat) - self.assertTrue(any(f.name == 'yuv420p' for f in formats)) - - self.assertIsNone(c.frame_rates) - - def test_codec_opus_decoder(self): - c = Codec('opus') - - self.assertEqual(c.name, 'opus') - self.assertEqual(c.long_name, 'Opus') - self.assertEqual(c.type, 'audio') - self.assertTrue(c.is_decoder) - self.assertFalse(c.is_encoder) - - # audio - self.assertIsNone(c.audio_formats) - self.assertIsNone(c.audio_rates) - - # video - self.assertIsNone(c.video_formats) - self.assertIsNone(c.frame_rates) - - @unittest.skipIf(opus_encoder_missing, 'Opus encoder is not available') - def test_codec_opus_encoder(self): - c = Codec('opus', 'w') - self.assertIn(c.name, ('opus', 'libopus')) - self.assertIn(c.long_name, ('Opus', 'libopus Opus')) - self.assertEqual(c.type, 'audio') - self.assertTrue(c.is_encoder) - self.assertFalse(c.is_decoder) - - # audio - formats = c.audio_formats - self.assertTrue(formats) - self.assertIsInstance(formats[0], AudioFormat) - self.assertTrue(any(f.name in ['flt', 'fltp'] for f in formats)) - - self.assertIsNotNone(c.audio_rates) - self.assertIn(48000, c.audio_rates) - - # video - self.assertIsNone(c.video_formats) - self.assertIsNone(c.frame_rates) - - def test_codecs_available(self): - self.assertTrue(codecs_available) + +def test_codec_bogus() -> None: + with pytest.raises(UnknownCodecError): + Codec("bogus123") + with pytest.raises(UnknownCodecError): + Codec("bogus123", "w") + + +def test_codec_mpeg4_decoder() -> None: + c = Codec("mpeg4") + + assert c.name == "mpeg4" + assert c.long_name == "MPEG-4 part 2" + assert c.type == "video" + assert c.id in (12, 13) + assert c.is_decoder + assert not c.is_encoder + assert c.delay + + assert c.audio_formats is None and c.audio_rates is None + + # formats = c.video_formats + # assert formats + # assert isinstance(formats[0], VideoFormat) + # assert any(f.name == "yuv420p" for f in formats) + + assert c.frame_rates is None + + +def test_codec_mpeg4_encoder() -> None: + c = Codec("mpeg4", "w") + assert c.name == "mpeg4" + assert c.long_name == "MPEG-4 part 2" + assert c.type == "video" + assert c.id in (12, 13) + assert c.is_encoder + assert not c.is_decoder + assert c.delay + + assert c.audio_formats is None and c.audio_rates is None + + formats = c.video_formats + assert formats + assert isinstance(formats[0], VideoFormat) + assert any(f.name == "yuv420p" for f in formats) + assert c.frame_rates is None + + +def test_codec_opus_decoder() -> None: + c = Codec("opus") + + assert c.name == "opus" + assert c.long_name == "Opus" + assert c.type == "audio" + assert c.is_decoder + assert not c.is_encoder + assert c.delay + + assert c.audio_formats is None and c.audio_rates is None + assert c.video_formats is None and c.frame_rates is None + + +def test_codec_opus_encoder() -> None: + c = Codec("opus", "w") + assert c.name in ("opus", "libopus") + assert c.canonical_name == "opus" + assert c.long_name in ("Opus", "libopus Opus") + assert c.type == "audio" + assert c.is_encoder + assert not c.is_decoder + assert c.delay + + # audio + formats = c.audio_formats + assert formats + assert isinstance(formats[0], AudioFormat) + assert any(f.name in ("flt", "fltp") for f in formats) + + assert c.audio_rates is not None + assert 48000 in c.audio_rates + + assert c.video_formats is None and c.frame_rates is None + + +def test_codecs_available() -> None: + assert codecs_available + + +def test_find_best_pix_fmt_of_list_empty() -> None: + best, loss = find_best_pix_fmt_of_list([], "rgb24") + assert best is None + assert loss == 0 + assert loss is PixFmtLoss.NONE + + +@pytest.mark.parametrize( + "pix_fmts,src_pix_fmt,expected_best", + [ + (["rgb24", "yuv420p"], "rgb24", "rgb24"), + (["rgb24"], "yuv420p", "rgb24"), + (["yuv420p"], "rgb24", "yuv420p"), + ([VideoFormat("yuv420p")], VideoFormat("rgb24"), "yuv420p"), + ( + ["yuv420p", "yuv444p", "gray", "rgb24", "rgba", "bgra", "yuyv422"], + "rgba", + "rgba", + ), + ], +) +def test_find_best_pix_fmt_of_list_best(pix_fmts, src_pix_fmt, expected_best) -> None: + best, loss = find_best_pix_fmt_of_list(pix_fmts, src_pix_fmt) + assert best is not None + assert best.name == expected_best + assert isinstance(loss, int) + + +@pytest.mark.parametrize( + "pix_fmts,src_pix_fmt", + [ + (["__unknown_pix_fmt"], "rgb24"), + (["rgb24"], "__unknown_pix_fmt"), + ], +) +def test_find_best_pix_fmt_of_list_unknown_pix_fmt(pix_fmts, src_pix_fmt) -> None: + with pytest.raises(ValueError, match="not a pixel format"): + find_best_pix_fmt_of_list(pix_fmts, src_pix_fmt) + + +@pytest.mark.parametrize( + "pix_fmts,src_pix_fmt", + [ + (["rgb24", "bgr24", "gray", "yuv420p", "yuv444p", "yuyv422"], "nv12"), + (["yuv420p", "yuv444p", "gray", "yuv420p"], "rgb24"), + (["rgb24", "rgba", "bgra", "rgb24", "gray"], "yuv420p"), + ], +) +def test_find_best_pix_fmt_of_list_picks_from_list(pix_fmts, src_pix_fmt) -> None: + best, loss = find_best_pix_fmt_of_list(pix_fmts, src_pix_fmt) + assert best is not None + assert best.name in set(pix_fmts) + assert isinstance(loss, int) + + +def test_find_best_pix_fmt_of_list_alpha_loss_flagged_when_used() -> None: + best, loss = find_best_pix_fmt_of_list(["rgb24"], "rgba", has_alpha=True) + assert best is not None + assert best.name == "rgb24" + assert loss != 0 + assert isinstance(loss, PixFmtLoss) + assert loss & PixFmtLoss.ALPHA + + +def test_find_best_pix_fmt_of_list_loss_flags() -> None: + # An identical format loses nothing. + _, loss = find_best_pix_fmt_of_list(["yuv420p"], "yuv420p") + assert loss is PixFmtLoss.NONE + + # Converting color to grayscale drops the chroma planes. + _, loss = find_best_pix_fmt_of_list(["gray"], "rgb24") + assert isinstance(loss, PixFmtLoss) + assert loss & PixFmtLoss.CHROMA diff --git a/tests/test_codec_context.py b/tests/test_codec_context.py index 9f68785bf..272a1d45a 100644 --- a/tests/test_codec_context.py +++ b/tests/test_codec_context.py @@ -1,22 +1,54 @@ -from fractions import Fraction -from unittest import SkipTest +from __future__ import annotations + import os +from collections.abc import Iterator +from fractions import Fraction +from typing import TypedDict, overload + +import pytest -from av import AudioResampler, Codec, Packet -from av.codec.codec import UnknownCodecError import av +from av import ( + AudioCodecContext, + AudioFrame, + AudioLayout, + AudioResampler, + AudioStream, + Codec, + Packet, + VideoCodecContext, + VideoFrame, +) +from av.codec.codec import UnknownCodecError +from av.codec.context import OptionFlags, OptionType +from av.video.frame import PictureType from .common import TestCase, fate_suite -def iter_frames(container, stream): - for packet in container.demux(stream): - for frame in packet.decode(): - yield frame - - -def iter_raw_frames(path, packet_sizes, ctx): - with open(path, 'rb') as f: +class Options(TypedDict, total=False): + b: str + crf: str + pix_fmt: str + width: int + height: int + max_frames: int + time_base: Fraction + gop_size: int + + +@overload +def iter_raw_frames( + path: str, packet_sizes: list[int], ctx: VideoCodecContext +) -> Iterator[VideoFrame]: ... +@overload +def iter_raw_frames( + path: str, packet_sizes: list[int], ctx: AudioCodecContext +) -> Iterator[AudioFrame]: ... +def iter_raw_frames( + path: str, packet_sizes: list[int], ctx: VideoCodecContext | AudioCodecContext +) -> Iterator[VideoFrame | AudioFrame]: + with open(path, "rb") as f: for i, size in enumerate(packet_sizes): packet = Packet(size) read_size = f.readinto(packet) @@ -26,6 +58,7 @@ def iter_raw_frames(path, packet_sizes, ctx): break for frame in ctx.decode(packet): yield frame + while True: try: frames = ctx.decode(None) @@ -38,240 +71,432 @@ def iter_raw_frames(path, packet_sizes, ctx): class TestCodecContext(TestCase): + def test_supported_options(self) -> None: + ctx = Codec("flac", "w").create() + supported = ctx.supported_options + generic = {option.name: option for option in supported.generic} + private = {option.name: option for option in supported.private} + + bit_rate = generic["b"] + assert bit_rate.type is OptionType.INT64 + assert bit_rate.default is not None + assert bit_rate.flags & OptionFlags.ENCODING_PARAM + assert bit_rate.flags & OptionFlags.AUDIO_PARAM + + strict_choices = {choice.name for choice in generic["strict"].choices} + assert "experimental" in strict_choices + + lpc_type = private["lpc_type"] + assert lpc_type.type is OptionType.INT + assert lpc_type.default == "-1" + assert {choice.name for choice in lpc_type.choices} >= { + "none", + "fixed", + "levinson", + } + + # Descriptors report FFmpeg's defaults, not values changed on this context. + ctx.bit_rate = 123456 + changed_supported = ctx.supported_options + changed_generic = {option.name: option for option in changed_supported.generic} + assert changed_generic["b"].default == bit_rate.default + + def test_global_quality(self): + ctx = Codec("mpeg4", "w").create() + ctx.global_quality = 5 + assert ctx.global_quality == 5 + + def test_level(self): + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + assert container.streams.video[0].codec_context.level == 41 + + ctx = Codec("mpeg4", "w").create() + ctx.level = 5 + assert ctx.level == 5 + + def test_field_order(self): + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + assert container.streams.video[0].codec_context.field_order == 2 + + ctx = Codec("mpeg4", "w").create() + ctx.field_order = 1 + assert ctx.field_order == 1 def test_skip_frame_default(self): - ctx = Codec('png', 'w').create() - self.assertEqual(ctx.skip_frame.name, 'DEFAULT') + ctx = Codec("png", "w").create() + assert ctx.skip_frame == "DEFAULT" + + def test_codec_delay(self) -> None: + with av.open(fate_suite("mkv/codec_delay_opus.mkv")) as container: + assert container.streams.audio[0].codec_context.delay == 312 + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + assert container.streams.video[0].codec_context.delay == 0 def test_codec_tag(self): - ctx = Codec('mpeg4', 'w').create() - self.assertEqual(ctx.codec_tag, '\x00\x00\x00\x00') - ctx.codec_tag = 'xvid' - self.assertEqual(ctx.codec_tag, 'xvid') + ctx = Codec("mpeg4", "w").create() + assert ctx.codec_tag == "\x00\x00\x00\x00" + ctx.codec_tag = "xvid" + assert ctx.codec_tag == "xvid" # wrong length - with self.assertRaises(ValueError) as cm: - ctx.codec_tag = 'bob' - self.assertEqual(str(cm.exception), 'Codec tag should be a 4 character string.') + with pytest.raises( + ValueError, match="Codec tag should be a 4 character string" + ): + ctx.codec_tag = "bob" # wrong type - with self.assertRaises(ValueError) as cm: + with pytest.raises( + ValueError, match="Codec tag should be a 4 character string" + ): ctx.codec_tag = 123 - self.assertEqual(str(cm.exception), 'Codec tag should be a 4 character string.') - with av.open(fate_suite('h264/interlaced_crop.mp4')) as container: - self.assertEqual(container.streams[0].codec_tag, 'avc1') + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + assert container.streams[0].codec_tag == "avc1" def test_decoder_extradata(self): - ctx = av.codec.Codec('h264', 'r').create() - self.assertEqual(ctx.extradata, None) - self.assertEqual(ctx.extradata_size, 0) + ctx = av.codec.Codec("h264", "r").create() + assert ctx.extradata is None + assert ctx.extradata_size == 0 ctx.extradata = b"123" - self.assertEqual(ctx.extradata, b"123") - self.assertEqual(ctx.extradata_size, 3) + assert ctx.extradata == b"123" + assert ctx.extradata_size == 3 ctx.extradata = b"54321" - self.assertEqual(ctx.extradata, b"54321") - self.assertEqual(ctx.extradata_size, 5) + assert ctx.extradata == b"54321" + assert ctx.extradata_size == 5 ctx.extradata = None - self.assertEqual(ctx.extradata, None) - self.assertEqual(ctx.extradata_size, 0) + assert ctx.extradata is None + assert ctx.extradata_size == 0 + + def test_decoder_gop_size(self) -> None: + ctx = av.codec.Codec("h264", "r").create("video") + + with pytest.raises(RuntimeError): + ctx.gop_size + + def test_decoder_timebase(self) -> None: + ctx = av.codec.Codec("h264", "r").create() + + with pytest.raises(RuntimeError): + ctx.time_base - def test_encoder_extradata(self): - ctx = av.codec.Codec('h264', 'w').create() - self.assertEqual(ctx.extradata, None) - self.assertEqual(ctx.extradata_size, 0) + with pytest.raises(RuntimeError): + ctx.time_base = Fraction(1, 25) + def test_encoder_extradata(self) -> None: + ctx = av.codec.Codec("h264", "w").create() + assert ctx.extradata is None + assert ctx.extradata_size == 0 + + ctx.extradata = b"123" + assert ctx.extradata == b"123" + assert ctx.extradata_size == 3 + + def test_encoder_pix_fmt(self) -> None: + ctx = av.codec.Codec("h264", "w").create("video") + + assert ctx.pix_fmt is None + + # valid format + ctx.pix_fmt = "yuv420p" + assert ctx.pix_fmt == "yuv420p" + + # invalid format with self.assertRaises(ValueError) as cm: - ctx.extradata = b"123" - self.assertEqual(str(cm.exception), "Can only set extradata for decoders.") + ctx.pix_fmt = "__unknown_pix_fmt" + assert str(cm.exception) == "not a pixel format: '__unknown_pix_fmt'" + assert ctx.pix_fmt == "yuv420p" + + def test_bits_per_coded_sample(self): + with av.open(fate_suite("qtrle/aletrek-rle.mov")) as container: + stream = container.streams.video[0] + stream.bits_per_coded_sample = 32 + + for packet in container.demux(stream): + for frame in packet.decode(): + pass + assert isinstance(packet.stream, av.VideoStream) + assert packet.stream.bits_per_coded_sample == 32 + + with av.open(fate_suite("qtrle/aletrek-rle.mov")) as container: + stream = container.streams.video[0] + stream.bits_per_coded_sample = 31 + + with pytest.raises(av.error.InvalidDataError): + for _ in container.decode(stream): + pass + + with av.open(self.sandboxed("output.mov"), "w") as output: + stream = output.add_stream("qtrle") + + with pytest.raises(ValueError): + stream.codec_context.bits_per_coded_sample = 32 + + def test_decode_keeps_frames_before_error(self) -> None: + # Regression test for #2044: a single packet may contain a valid frame + # followed by undecodable bytes (e.g. a truncated final FLAC frame). + # FFmpeg yields the good frame and only reports the error on the next + # receive, so decode() must return what it already decoded instead of + # raising and discarding it. + import io + + import numpy as np + + # Build an in-memory FLAC stream with several independent frames. + buf = io.BytesIO() + with av.open(buf, "w", format="flac") as output: + stream = output.add_stream("flac", rate=44100) + assert isinstance(stream, AudioStream) + stream.format = "s16" + stream.layout = "mono" + n = 0 + for _ in range(8): + samples = 4096 + t = (np.arange(n, n + samples) / 44100).astype(np.float32) + sig = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16) + frame = AudioFrame.from_ndarray( + sig.reshape(1, -1), format="s16", layout="mono" + ) + frame.rate = 44100 + frame.pts = n + for packet in stream.encode(frame): + output.mux(packet) + n += samples + for packet in stream.encode(None): + output.mux(packet) + + # Re-read the raw (parser-split) frame packets and decoder extradata. + buf.seek(0) + with av.open(buf, "r") as container: + audio = container.streams.audio[0] + extradata = audio.codec_context.extradata + packets = [bytes(p) for p in container.demux(audio) if p.size] + + assert len(packets) >= 3 + + def make_ctx() -> AudioCodecContext: + ctx = Codec("flac", "r").create("audio") + assert isinstance(ctx, AudioCodecContext) + ctx.extradata = extradata + return ctx + + # A leading frame that decodes cleanly on its own. + good_frames = make_ctx().decode(Packet(packets[0])) + good_samples = sum(f.samples for f in good_frames) + assert good_samples > 0 + + # Trailing bytes that are undecodable on their own. + corrupt: bytes | None = None + for raw in packets[1:]: + chunk = raw[: len(raw) // 2] + try: + make_ctx().decode(Packet(chunk)) + except av.error.InvalidDataError: + corrupt = chunk + break + assert corrupt is not None, "could not construct an undecodable chunk" - def test_parse(self): + # [valid frame][undecodable bytes] in one packet must still yield the + # valid frame rather than raising and dropping everything. + frames = make_ctx().decode(Packet(packets[0] + corrupt)) + assert sum(f.samples for f in frames) >= good_samples + # A packet that is *only* undecodable bytes still raises. + with pytest.raises(av.error.InvalidDataError): + make_ctx().decode(Packet(corrupt)) + + def test_parse(self) -> None: # This one parses into a single packet. - self._assert_parse('mpeg4', fate_suite('h264/interlaced_crop.mp4')) + self._assert_parse("mpeg4", fate_suite("h264/interlaced_crop.mp4")) # This one parses into many small packets. - self._assert_parse('mpeg2video', fate_suite('mpeg2/mpeg2_field_encoding.ts')) - - def _assert_parse(self, codec_name, path): + self._assert_parse("mpeg2video", fate_suite("mpeg2/mpeg2_field_encoding.ts")) + def _assert_parse(self, codec_name: str, path: str) -> None: fh = av.open(path) packets = [] for packet in fh.demux(video=0): packets.append(packet) - full_source = b''.join(p.to_bytes() for p in packets) + full_source = b"".join(bytes(p) for p in packets) for size in 1024, 8192, 65535: - ctx = Codec(codec_name).create() packets = [] for i in range(0, len(full_source), size): - block = full_source[i:i + size] + block = full_source[i : i + size] packets.extend(ctx.parse(block)) packets.extend(ctx.parse()) - parsed_source = b''.join(p.to_bytes() for p in packets) - self.assertEqual(len(parsed_source), len(full_source)) - self.assertEqual(full_source, parsed_source) + parsed_source = b"".join(bytes(p) for p in packets) + assert len(parsed_source) == len(full_source) + assert full_source == parsed_source + def test_parse_assigns_packet_timing(self) -> None: + # Regression test for #1919: the parser-inferred timing information + # should be propagated onto the returned packets. + path = fate_suite("mpeg2/mpeg2_field_encoding.ts") + full_source = b"".join(bytes(p) for p in av.open(path).demux(video=0)) -class TestEncoding(TestCase): + ctx = Codec("mpeg2video").create() + packets = [] + for i in range(0, len(full_source), 4096): + packets.extend(ctx.parse(full_source[i : i + 4096])) + packets.extend(ctx.parse()) + + # The parser is able to determine the byte position for this stream, + # so at least some packets should carry it through. + assert any(p.pos is not None for p in packets) - def test_encoding_png(self): - self.image_sequence_encode('png') - def test_encoding_mjpeg(self): - self.image_sequence_encode('mjpeg') +class TestEncoding(TestCase): + def test_encoding_png(self) -> None: + self.image_sequence_encode("png") - def test_encoding_tiff(self): - self.image_sequence_encode('tiff') + def test_encoding_mjpeg(self) -> None: + self.image_sequence_encode("mjpeg") - def image_sequence_encode(self, codec_name): + def test_encoding_tiff(self) -> None: + self.image_sequence_encode("tiff") + def image_sequence_encode(self, codec_name: str) -> None: try: - codec = Codec(codec_name, 'w') + codec = Codec(codec_name, "w") except UnknownCodecError: - raise SkipTest() + pytest.skip(f"Unknown codec: {codec_name}") - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + container = av.open(fate_suite("h264/interlaced_crop.mp4")) video_stream = container.streams.video[0] width = 640 height = 480 - ctx = codec.create() + ctx = codec.create("video") + assert ctx.codec.video_formats pix_fmt = ctx.codec.video_formats[0].name ctx.width = width ctx.height = height - ctx.time_base = video_stream.codec_context.time_base + + assert video_stream.time_base is not None + ctx.time_base = video_stream.time_base ctx.pix_fmt = pix_fmt ctx.open() frame_count = 1 path_list = [] - for frame in iter_frames(container, video_stream): - + for frame in container.decode(video_stream): new_frame = frame.reformat(width, height, pix_fmt) new_packets = ctx.encode(new_frame) - self.assertEqual(len(new_packets), 1) + assert len(new_packets) == 1 new_packet = new_packets[0] - path = self.sandboxed('%s/encoder.%04d.%s' % ( - codec_name, - frame_count, - codec_name if codec_name != 'mjpeg' else 'jpg', - )) + path = self.sandboxed( + f"{codec_name}/encoder.{frame_count:04d}." + f"{codec_name if codec_name != 'mjpeg' else 'jpg'}" + ) path_list.append(path) - with open(path, 'wb') as f: + with open(path, "wb") as f: f.write(new_packet) frame_count += 1 if frame_count > 5: break - ctx = av.Codec(codec_name, 'r').create() + ctx = av.Codec(codec_name, "r").create("video") for path in path_list: - with open(path, 'rb') as f: + with open(path, "rb") as f: size = os.fstat(f.fileno()).st_size packet = Packet(size) size = f.readinto(packet) frame = ctx.decode(packet)[0] - self.assertEqual(frame.width, width) - self.assertEqual(frame.height, height) - self.assertEqual(frame.format.name, pix_fmt) - - def test_encoding_h264(self): - self.video_encoding('libx264', {'crf': '19'}) - - def test_encoding_mpeg4(self): - self.video_encoding('mpeg4') - - def test_encoding_xvid(self): - self.video_encoding('mpeg4', codec_tag='xvid') - - def test_encoding_mpeg1video(self): - self.video_encoding('mpeg1video') - - def test_encoding_dvvideo(self): - options = {'pix_fmt': 'yuv411p', - 'width': 720, - 'height': 480} - self.video_encoding('dvvideo', options) - - def test_encoding_dnxhd(self): - options = {'b': '90M', # bitrate - 'pix_fmt': 'yuv422p', - 'width': 1920, - 'height': 1080, - 'time_base': '1001/30000', - 'max_frames': 5} - self.video_encoding('dnxhd', options) - - def video_encoding(self, codec_name, options={}, codec_tag=None): - + assert frame.width == width + assert frame.height == height + assert frame.format.name == pix_fmt + + def test_encoding_h264(self) -> None: + self.video_encoding("h264", {"crf": "19"}) + + def test_encoding_mpeg4(self) -> None: + self.video_encoding("mpeg4") + + def test_encoding_xvid(self) -> None: + self.video_encoding("mpeg4", codec_tag="xvid") + + def test_encoding_mpeg1video(self) -> None: + self.video_encoding("mpeg1video") + + def test_encoding_dvvideo(self) -> None: + options: Options = {"pix_fmt": "yuv411p", "width": 720, "height": 480} + self.video_encoding("dvvideo", options) + + def test_encoding_dnxhd(self) -> None: + options: Options = { + "b": "90M", # bitrate + "pix_fmt": "yuv422p", + "width": 1920, + "height": 1080, + "time_base": Fraction(1001, 30_000), + "max_frames": 5, + } + self.video_encoding("dnxhd", options) + + def video_encoding( + self, + codec_name: str, + options: Options = {}, + codec_tag: str | None = None, + ) -> None: try: - codec = Codec(codec_name, 'w') + codec = Codec(codec_name, "w") except UnknownCodecError: - raise SkipTest() + pytest.skip(f"Unknown codec: {codec_name}") - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + container = av.open(fate_suite("h264/interlaced_crop.mp4")) video_stream = container.streams.video[0] - pix_fmt = options.pop('pix_fmt', 'yuv420p') - width = options.pop('width', 640) - height = options.pop('height', 480) - max_frames = options.pop('max_frames', 50) - time_base = options.pop('time_base', video_stream.codec_context.time_base) + assert video_stream.time_base is not None + + pix_fmt = options.pop("pix_fmt", "yuv420p") + width = options.pop("width", 640) + height = options.pop("height", 480) + max_frames = options.pop("max_frames", 50) + time_base = options.pop("time_base", video_stream.time_base) + gop_size = options.pop("gop_size", 20) - ctx = codec.create() + ctx = codec.create("video") ctx.width = width ctx.height = height ctx.time_base = time_base ctx.framerate = 1 / ctx.time_base ctx.pix_fmt = pix_fmt - ctx.options = options # TODO + ctx.gop_size = gop_size + ctx.options = options # type: ignore if codec_tag: ctx.codec_tag = codec_tag ctx.open() - path = self.sandboxed('encoder.%s' % codec_name) + path = self.sandboxed(f"encoder.{codec_name}") packet_sizes = [] frame_count = 0 - with open(path, 'wb') as f: - - for frame in iter_frames(container, video_stream): - - """ - bad_frame = frame.reformat(width, 100, pix_fmt) - with self.assertRaises(ValueError): - ctx.encode(bad_frame) + with open(path, "wb") as f: + for frame in container.decode(video_stream): + new_frame = frame.reformat(width, height, pix_fmt) - bad_frame = frame.reformat(100, height, pix_fmt) - with self.assertRaises(ValueError): - ctx.encode(bad_frame) + # reset the picture type + new_frame.pict_type = PictureType.NONE - bad_frame = frame.reformat(width, height, "rgb24") - with self.assertRaises(ValueError): - ctx.encode(bad_frame) - """ - - if frame: - frame_count += 1 - - new_frame = frame.reformat(width, height, pix_fmt) if frame else None for packet in ctx.encode(new_frame): packet_sizes.append(packet.size) f.write(packet) + frame_count += 1 if frame_count >= max_frames: break @@ -280,116 +505,115 @@ def video_encoding(self, codec_name, options={}, codec_tag=None): f.write(packet) dec_codec_name = codec_name - if codec_name == 'libx264': - dec_codec_name = 'h264' + if codec_name == "libx264": + dec_codec_name = "h264" - ctx = av.Codec(dec_codec_name, 'r').create() + ctx = av.Codec(dec_codec_name, "r").create("video") ctx.open() + keyframe_indices = [] decoded_frame_count = 0 for frame in iter_raw_frames(path, packet_sizes, ctx): decoded_frame_count += 1 - self.assertEqual(frame.width, width) - self.assertEqual(frame.height, height) - self.assertEqual(frame.format.name, pix_fmt) - - self.assertEqual(frame_count, decoded_frame_count) - - def test_encoding_pcm_s24le(self): - self.audio_encoding('pcm_s24le') - - def test_encoding_aac(self): - self.audio_encoding('aac') - - def test_encoding_mp2(self): - self.audio_encoding('mp2') - - def audio_encoding(self, codec_name): - + assert frame.width == width + assert frame.height == height + assert frame.format.name == pix_fmt + if frame.key_frame: + keyframe_indices.append(decoded_frame_count) + + assert frame_count == decoded_frame_count + + assert isinstance( + all(keyframe_index for keyframe_index in keyframe_indices), int + ) + decoded_gop_sizes = [ + j - i for i, j in zip(keyframe_indices[:-1], keyframe_indices[1:]) + ] + if codec_name in ("dvvideo", "dnxhd") and all( + i == 1 for i in decoded_gop_sizes + ): + pytest.skip() + for i in decoded_gop_sizes: + assert i == gop_size + + final_gop_size = decoded_frame_count - max(keyframe_indices) + assert final_gop_size < gop_size + + def test_encoding_pcm_s24le(self) -> None: + self.audio_encoding("pcm_s24le") + + def test_encoding_aac(self) -> None: + self.audio_encoding("aac") + + def test_encoding_mp2(self) -> None: + self.audio_encoding("mp2") + + def audio_encoding(self, codec_name: str) -> None: + self._audio_encoding(codec_name=codec_name, channel_layout="stereo") + self._audio_encoding( + codec_name=codec_name, channel_layout=AudioLayout("stereo") + ) + + def _audio_encoding( + self, *, codec_name: str, channel_layout: str | AudioLayout + ) -> None: try: - codec = Codec(codec_name, 'w') + codec = Codec(codec_name, "w") except UnknownCodecError: - raise SkipTest() + pytest.skip(f"Unknown codec: {codec_name}") + + ctx = codec.create(kind="audio") - ctx = codec.create() if ctx.codec.experimental: - raise SkipTest() + pytest.skip(f"Experimental codec: {codec_name}") + assert ctx.codec.audio_formats sample_fmt = ctx.codec.audio_formats[-1].name sample_rate = 48000 - channel_layout = "stereo" - channels = 2 ctx.time_base = Fraction(1) / sample_rate ctx.sample_rate = sample_rate ctx.format = sample_fmt ctx.layout = channel_layout - ctx.channels = channels ctx.open() resampler = AudioResampler(sample_fmt, channel_layout, sample_rate) - container = av.open(fate_suite('audio-reference/chorusnoise_2ch_44kHz_s16.wav')) + container = av.open(fate_suite("audio-reference/chorusnoise_2ch_44kHz_s16.wav")) audio_stream = container.streams.audio[0] - path = self.sandboxed('encoder.%s' % codec_name) + path = self.sandboxed(f"encoder.{codec_name}") samples = 0 packet_sizes = [] - with open(path, 'wb') as f: - for frame in iter_frames(container, audio_stream): - - # We need to let the encoder retime. - frame.pts = None - - """ - bad_resampler = AudioResampler(sample_fmt, "mono", sample_rate) - bad_frame = bad_resampler.resample(frame) - with self.assertRaises(ValueError): - next(encoder.encode(bad_frame)) - - bad_resampler = AudioResampler(sample_fmt, channel_layout, 3000) - bad_frame = bad_resampler.resample(frame) + with open(path, "wb") as f: + for frame in container.decode(audio_stream): + resampled_frames = resampler.resample(frame) + for resampled_frame in resampled_frames: + assert resampled_frame.time_base == Fraction(1, 48000) + samples += resampled_frame.samples - with self.assertRaises(ValueError): - next(encoder.encode(bad_frame)) - - bad_resampler = AudioResampler('u8', channel_layout, 3000) - bad_frame = bad_resampler.resample(frame) - - with self.assertRaises(ValueError): - next(encoder.encode(bad_frame)) - """ - - resampled_frame = resampler.resample(frame) - samples += resampled_frame.samples - - for packet in ctx.encode(resampled_frame): - # bytearray because python can - # freaks out if the first byte is NULL - f.write(bytearray(packet)) - packet_sizes.append(packet.size) + for packet in ctx.encode(resampled_frame): + assert packet.time_base == Fraction(1, 48000) + packet_sizes.append(packet.size) + f.write(packet) for packet in ctx.encode(None): + assert packet.time_base == Fraction(1, 48000) packet_sizes.append(packet.size) - f.write(bytearray(packet)) + f.write(packet) - ctx = Codec(codec_name, 'r').create() - ctx.time_base = Fraction(1) / sample_rate + ctx = Codec(codec_name, "r").create("audio") ctx.sample_rate = sample_rate ctx.format = sample_fmt ctx.layout = channel_layout - ctx.channels = channels ctx.open() result_samples = 0 - # should have more asserts but not sure what to check - # libav and ffmpeg give different results - # so can really use checksums for frame in iter_raw_frames(path, packet_sizes, ctx): result_samples += frame.samples - self.assertEqual(frame.rate, sample_rate) - self.assertEqual(len(frame.layout.channels), channels) + assert frame.sample_rate == sample_rate + assert frame.layout.nb_channels == 2 diff --git a/tests/test_colorspace.py b/tests/test_colorspace.py new file mode 100644 index 000000000..1dec93e20 --- /dev/null +++ b/tests/test_colorspace.py @@ -0,0 +1,170 @@ +import pytest + +import av +from av.video.reformatter import ( + ColorPrimaries, + ColorRange, + Colorspace, + ColorTrc, +) + +from .common import fate_suite + + +def test_penguin_joke() -> None: + container = av.open( + fate_suite("amv/MTV_high_res_320x240_sample_Penguin_Joke_MTV_from_WMV.amv") + ) + stream = container.streams.video[0] + + assert stream.codec_context.color_range == 2 + assert stream.codec_context.color_range == ColorRange.JPEG + + assert stream.codec_context.color_primaries == 2 + assert stream.codec_context.color_trc == 2 + + assert stream.codec_context.colorspace == 5 + assert stream.codec_context.colorspace == Colorspace.ITU601 + + for frame in container.decode(stream): + assert frame.color_range == ColorRange.JPEG # a.k.a "pc" + assert frame.colorspace == Colorspace.ITU601 + return + + +def test_sky_timelapse() -> None: + container = av.open( + av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") + ) + stream = container.streams.video[0] + + assert stream.disposition == av.stream.Disposition.default + + assert stream.codec_context.color_range == 1 + assert stream.codec_context.color_range == ColorRange.MPEG + assert stream.codec_context.color_primaries == 1 + assert stream.codec_context.color_trc == 1 + assert stream.codec_context.colorspace == 1 + + +def test_frame_color_trc_property() -> None: + frame = av.VideoFrame(width=64, height=64, format="rgb24") + assert frame.color_trc == ColorTrc.UNSPECIFIED + + frame.color_trc = ColorTrc.IEC61966_2_4 + assert frame.color_trc == ColorTrc.IEC61966_2_4 + + frame.color_trc = ColorTrc.BT709 + assert frame.color_trc == ColorTrc.BT709 + + +def test_frame_color_primaries_property() -> None: + frame = av.VideoFrame(width=64, height=64, format="rgb24") + assert frame.color_primaries == ColorPrimaries.UNSPECIFIED + + frame.color_primaries = ColorPrimaries.BT709 + assert frame.color_primaries == ColorPrimaries.BT709 + assert frame.color_primaries == 1 # AVCOL_PRI_BT709 + + +def test_reformat_dst_color_trc() -> None: + # Reformat a frame and tag it with sRGB transfer characteristic. + frame = av.VideoFrame(width=64, height=64, format="yuv420p") + rgb = frame.reformat( + format="rgb24", + dst_colorspace=Colorspace.ITU709, + dst_color_trc=ColorTrc.IEC61966_2_4, + ) + assert rgb.format.name == "rgb24" + assert rgb.colorspace == Colorspace.ITU709 + assert rgb.color_trc == ColorTrc.IEC61966_2_4 + + +def test_reformat_dst_color_primaries() -> None: + frame = av.VideoFrame(width=64, height=64, format="yuv420p") + rgb = frame.reformat( + format="rgb24", + dst_color_primaries=ColorPrimaries.BT709, + ) + assert rgb.color_primaries == ColorPrimaries.BT709 + + +def test_reformat_preserves_color_trc() -> None: + # When dst_color_trc is not specified, the source frame's value is preserved. + frame = av.VideoFrame(width=64, height=64, format="yuv420p") + frame.color_trc = ColorTrc.BT709 + rgb = frame.reformat(format="rgb24") + assert rgb.color_trc == ColorTrc.BT709 + + +def test_reformat_preserves_color_primaries() -> None: + # When dst_color_primaries is not specified, the source frame's value is preserved. + frame = av.VideoFrame(width=64, height=64, format="yuv420p") + frame.color_primaries = ColorPrimaries.BT709 + rgb = frame.reformat(format="rgb24") + assert rgb.color_primaries == ColorPrimaries.BT709 + + +@pytest.mark.parametrize( + ("colorspace", "expected"), + [ + (Colorspace.ITU709, Colorspace.ITU709), + (Colorspace.FCC, Colorspace.FCC), + (Colorspace.ITU601, 6), # AVCOL_SPC_SMPTE170M + (Colorspace.SMPTE240M, Colorspace.SMPTE240M), + (Colorspace.BT2020, Colorspace.BT2020), + ], +) +def test_reformat_dst_colorspace_metadata( + colorspace: Colorspace, expected: Colorspace | int +) -> None: + frame = av.VideoFrame(width=64, height=64, format="yuv420p") + rgb = frame.reformat(format="rgb24", dst_colorspace=colorspace) + assert rgb.colorspace == expected + + +# RESERVED0 (0) and RESERVED (3) primaries/transfer values, plus a couple of +# transfer functions swscale can't handle (LOG / LOG_SQRT). Real VP9 and NVDEC +# streams routinely tag frames with these. sws_scale_frame (used since 17.0.0) +# validates these fields and rejects them with EOPNOTSUPP, which regressed a +# plain reformat/to_ndarray to "rgb24" (#2208). The pre-17.0 sws_scale ignored +# them, and a transfer/primaries conversion should stay opt-in. +@pytest.mark.parametrize( + ("color_primaries", "color_trc"), + [ + (3, 3), # RESERVED / RESERVED + (0, 0), # RESERVED0 / RESERVED0 + (3, 2), # reserved primaries only + (2, 3), # reserved transfer only + (2, 9), # AVCOL_TRC_LOG (unsupported by swscale) + (2, 10), # AVCOL_TRC_LOG_SQRT (unsupported by swscale) + ], +) +def test_reformat_unsupported_color_metadata( + color_primaries: int, color_trc: int +) -> None: + frame = av.VideoFrame(width=64, height=64, format="yuv420p") + frame.colorspace = Colorspace.ITU709 + frame.color_primaries = color_primaries + frame.color_trc = color_trc + + # Neither of these should raise OSError(EOPNOTSUPP). + rgb = frame.reformat(format="rgb24") + assert rgb.format.name == "rgb24" + array = frame.to_ndarray(format="rgb24") + assert array.shape == (64, 64, 3) + + # The reformat must not mutate the source frame's metadata. + assert frame.color_primaries == color_primaries + assert frame.color_trc == color_trc + + # The BT.709 matrix is still applied even though the transfer/primaries are + # unsupported: a neutral gray must stay gray. + gray = av.VideoFrame(width=64, height=64, format="yuv420p") + gray.colorspace = Colorspace.ITU709 + gray.color_primaries = color_primaries + gray.color_trc = color_trc + for plane, value in zip(gray.planes, (128, 128, 128)): + plane.update(bytes([value]) * plane.buffer_size) + out = gray.to_ndarray(format="rgb24") + assert out.min() == out.max() == out[0, 0, 0] diff --git a/tests/test_container.py b/tests/test_container.py deleted file mode 100644 index fa7052395..000000000 --- a/tests/test_container.py +++ /dev/null @@ -1,27 +0,0 @@ -import sys -import unittest - -import av - -from .common import TestCase, fate_suite, is_windows, skip_tests - - -# On Windows, Python 3.0 - 3.5 have issues handling unicode filenames. -# Starting with Python 3.6 the situation is saner thanks to PEP 529: -# -# https://www.python.org/dev/peps/pep-0529/ - -broken_unicode = is_windows and sys.version_info < (3, 6) - - -class TestContainers(TestCase): - - def test_context_manager(self): - with av.open(fate_suite('h264/interlaced_crop.mp4')) as container: - self.assertEqual(container.format.long_name, 'QuickTime / MOV') - self.assertEqual(len(container.streams), 1) - - @unittest.skipIf(broken_unicode or 'unicode_filename' in skip_tests, 'Unicode filename handling is broken') - def test_unicode_filename(self): - - av.open(self.sandboxed(u'¢∞§¶•ªº.mov'), 'w') diff --git a/tests/test_containerformat.py b/tests/test_containerformat.py index e5e5a9698..ca0b72fd7 100644 --- a/tests/test_containerformat.py +++ b/tests/test_containerformat.py @@ -1,45 +1,79 @@ -from av import ContainerFormat, formats_available - -from .common import TestCase - - -class TestContainerFormats(TestCase): - - def test_matroska(self): - fmt = ContainerFormat('matroska') - self.assertTrue(fmt.is_input) - self.assertTrue(fmt.is_output) - self.assertEqual(fmt.name, 'matroska') - self.assertEqual(fmt.long_name, 'Matroska') - self.assertIn('mkv', fmt.extensions) - self.assertFalse(fmt.no_file) - - def test_mov(self): - fmt = ContainerFormat('mov') - self.assertTrue(fmt.is_input) - self.assertTrue(fmt.is_output) - self.assertEqual(fmt.name, 'mov') - self.assertEqual(fmt.long_name, 'QuickTime / MOV') - self.assertIn('mov', fmt.extensions) - self.assertFalse(fmt.no_file) - - def test_stream_segment(self): - # This format goes by two names, check both. - fmt = ContainerFormat('stream_segment') - self.assertFalse(fmt.is_input) - self.assertTrue(fmt.is_output) - self.assertEqual(fmt.name, 'stream_segment') - self.assertEqual(fmt.long_name, 'streaming segment muxer') - self.assertEqual(fmt.extensions, set()) - self.assertTrue(fmt.no_file) - - fmt = ContainerFormat('ssegment') - self.assertFalse(fmt.is_input) - self.assertTrue(fmt.is_output) - self.assertEqual(fmt.name, 'ssegment') - self.assertEqual(fmt.long_name, 'streaming segment muxer') - self.assertEqual(fmt.extensions, set()) - self.assertTrue(fmt.no_file) - - def test_formats_available(self): - self.assertTrue(formats_available) +from av import ContainerFormat, formats_available, open + +from .common import fate_suite + + +def test_matroska() -> None: + with open("test.mkv", "w") as container: + assert container.default_video_codec != "none" + assert container.default_audio_codec != "none" + assert container.default_subtitle_codec == "ass" + assert "ass" in container.supported_codecs + + fmt = ContainerFormat("matroska") + assert fmt.is_input and fmt.is_output + assert fmt.name == "matroska" + assert fmt.long_name == "Matroska" + assert "mkv" in fmt.extensions + assert not fmt.no_file + + +def test_mov() -> None: + with open("test.mov", "w") as container: + assert container.default_video_codec != "none" + assert container.default_audio_codec != "none" + assert container.default_subtitle_codec == "none" + assert "h264" in container.supported_codecs + + fmt = ContainerFormat("mov") + assert fmt.is_input and fmt.is_output + assert fmt.name == "mov" + assert fmt.long_name == "QuickTime / MOV" + assert "mov" in fmt.extensions + assert not fmt.no_file + + +def test_gif() -> None: + with open("test.gif", "w") as container: + assert container.default_video_codec == "gif" + assert container.default_audio_codec == "none" + assert container.default_subtitle_codec == "none" + assert "gif" in container.supported_codecs + + +def test_stream_segment() -> None: + # This format goes by two names, check both. + fmt = ContainerFormat("stream_segment") + assert not fmt.is_input and fmt.is_output + assert fmt.name == "stream_segment" + assert fmt.long_name == "streaming segment muxer" + assert fmt.extensions == set() + assert fmt.no_file + + fmt = ContainerFormat("ssegment") + assert not fmt.is_input and fmt.is_output + assert fmt.name == "ssegment" + assert fmt.long_name == "streaming segment muxer" + assert fmt.extensions == set() + assert fmt.no_file + + +def test_video_codec_id() -> None: + # Regression test for https://github.com/PyAV-Org/PyAV/issues/2243 + # video_codec_id allows overriding the codec used when opening a container. + AV_CODEC_ID_NONE = 0 + AV_CODEC_ID_H264 = 27 + + with open(fate_suite("h264/interlaced_crop.mp4")) as container: + assert container.video_codec_id == AV_CODEC_ID_NONE + container.video_codec_id = AV_CODEC_ID_H264 + assert container.video_codec_id == AV_CODEC_ID_H264 + + with open("test.mkv", "w") as container: + assert container.video_codec_id == AV_CODEC_ID_NONE + container.video_codec_id = AV_CODEC_ID_H264 + assert container.video_codec_id == AV_CODEC_ID_H264 + + +def test_formats_available() -> None: + assert formats_available diff --git a/tests/test_decode.py b/tests/test_decode.py index 5627f55f2..e0b55e952 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1,80 +1,399 @@ +import functools +import io +import os +import pathlib +from fractions import Fraction +from typing import cast + +import numpy as np +import pytest + import av +from av.sidedata.encparams import VideoEncParams +from av.subtitles.subtitle import SubtitleSet from .common import TestCase, fate_suite +@functools.cache +def make_h264_test_video(path: str) -> None: + """Generates a black H264 test video with two streams for testing hardware decoding.""" + + # We generate a file here that's designed to be as compatible as possible with hardware + # encoders. Hardware encoders are sometimes very picky and the errors we get are often + # opaque, so there is nothing much we (PyAV) can do. The user needs to figure that out + # if they want to use hwaccel. We only want to test the PyAV plumbing here. + # Our video is H264, 1280x720p (note that some decoders have a minimum resolution limit), 24fps, + # 8-bit yuv420p. + pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) + output_container = av.open(path, "w") + + streams = [] + for _ in range(2): + stream = output_container.add_stream("libx264", rate=24) + assert isinstance(stream, av.VideoStream) + stream.width = 1280 + stream.height = 720 + stream.pix_fmt = "yuv420p" + streams.append(stream) + + for _ in range(24): + frame = av.VideoFrame.from_ndarray( + np.zeros((720, 1280, 3), dtype=np.uint8), format="rgb24" + ) + for stream in streams: + for packet in stream.encode(frame): + output_container.mux(packet) + + for stream in streams: + for packet in stream.encode(): + output_container.mux(packet) + + output_container.close() + + class TestDecode(TestCase): + def test_decode_stream_without_codec_context(self) -> None: + buffer = io.BytesIO() + with av.open(buffer, "w", format="mp4") as output: + stream = output.add_mux_stream("h264", width=16, height=16) + packet = av.Packet(b"invalid") + packet.stream = stream + packet.pts = packet.dts = 0 + packet.time_base = Fraction(1, 1000) + output.mux(packet) + + # Keep the MP4 video stream while making its codec unknown to FFmpeg. + data = buffer.getvalue().replace(b"avc1", b"zzzz") + with av.open(io.BytesIO(data)) as container: + stream = container.streams.video[0] + assert stream.codec_context is None + with pytest.raises(av.DecoderNotFoundError): + list(container.decode(stream)) - def test_decoded_video_frame_count(self): + def test_mux_stream_without_codec_context(self) -> None: + with av.open(io.BytesIO(), "w", format="mp4") as output: + stream = output.add_mux_stream("h264", width=16, height=16) + assert stream.codec_context is None + with pytest.raises(av.EncoderNotFoundError): + stream.encode(None) - container = av.open(fate_suite('h264/interlaced_crop.mp4')) - video_stream = next(s for s in container.streams if s.type == 'video') + # A bitstream filter only needs to update codecpar for a mux stream. + av.BitStreamFilterContext("h264_mp4toannexb", "h264", out_stream=stream) - self.assertIs(video_stream, container.streams.video[0]) + def test_decoded_video_frame_count(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + video_stream = next(s for s in container.streams if s.type == "video") + + assert video_stream is container.streams.video[0] frame_count = 0 + for frame in container.decode(video_stream): + frame_count += 1 - for packet in container.demux(video_stream): - for frame in packet.decode(): - frame_count += 1 + assert frame_count == video_stream.frames - self.assertEqual(frame_count, video_stream.frames) + def test_decode_audio_corrupt(self) -> None: + # write an empty file + path = self.sandboxed("empty.flac") + with open(path, "wb"): + pass - def test_decode_audio_sample_count(self): + packet_count = 0 + frame_count = 0 + audio_frame: av.AudioFrame | None = None - container = av.open(fate_suite('audio-reference/chorusnoise_2ch_44kHz_s16.wav')) - audio_stream = next(s for s in container.streams if s.type == 'audio') + with av.open(path) as container: + for packet in container.demux(audio=0): + for frame in packet.decode(): + frame_count += 1 + audio_frame = frame + packet_count += 1 - self.assertIs(audio_stream, container.streams.audio[0]) + assert packet_count == 1 + assert frame_count == 0 - sample_count = 0 + def test_decode_audio_sample_count(self) -> None: + container = av.open(fate_suite("audio-reference/chorusnoise_2ch_44kHz_s16.wav")) + audio_stream = next(s for s in container.streams if s.type == "audio") - for packet in container.demux(audio_stream): - for frame in packet.decode(): - sample_count += frame.samples + assert audio_stream is container.streams.audio[0] + assert isinstance(audio_stream, av.AudioStream) + + sample_count = 0 - total_samples = (audio_stream.duration * audio_stream.rate.numerator) / audio_stream.time_base.denominator - self.assertEqual(sample_count, total_samples) + for frame in container.decode(audio_stream): + sample_count += frame.samples - def test_decoded_time_base(self): + assert audio_stream.duration is not None + assert audio_stream.time_base is not None + total_samples = ( + audio_stream.duration + * audio_stream.sample_rate.numerator + / audio_stream.time_base.denominator + ) + assert sample_count == total_samples - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_decoded_time_base(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) stream = container.streams.video[0] - codec_context = stream.codec_context - self.assertNotEqual(stream.time_base, codec_context.time_base) + assert stream.time_base == Fraction(1, 25) + + video_frame: av.VideoFrame | None = None for packet in container.demux(stream): for frame in packet.decode(): - self.assertEqual(packet.time_base, frame.time_base) - self.assertEqual(stream.time_base, frame.time_base) + video_frame = frame + assert not isinstance(frame, SubtitleSet) + assert packet.time_base == frame.time_base + assert stream.time_base == frame.time_base return - def test_decoded_motion_vectors(self): - - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_decoded_motion_vectors(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) stream = container.streams.video[0] codec_context = stream.codec_context codec_context.options = {"flags2": "+export_mvs"} - for packet in container.demux(stream): - for frame in packet.decode(): - vectors = frame.side_data.get('MOTION_VECTORS') - if frame.key_frame: - # Key frame don't have motion vectors - assert vectors is None - else: - assert len(vectors) > 0 - return + for frame in container.decode(stream): + vectors = frame.side_data.get("MOTION_VECTORS") + if frame.key_frame: + # Key frame don't have motion vectors + assert vectors is None + else: + assert vectors is not None and len(vectors) > 0 + return - def test_decoded_motion_vectors_no_flag(self): + def test_decoded_motion_vectors_no_flag(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + stream = container.streams.video[0] + + for frame in container.decode(stream): + vectors = frame.side_data.get("MOTION_VECTORS") + if not frame.key_frame: + assert vectors is None + return - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_decoded_video_enc_params(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) stream = container.streams.video[0] + stream.codec_context.options = {"export_side_data": "venc_params"} - for packet in container.demux(stream): - for frame in packet.decode(): - vectors = frame.side_data.get('MOTION_VECTORS') - if not frame.key_frame: - assert vectors is None - return + for frame in container.decode(stream): + video_enc_params = cast( + VideoEncParams, + frame.side_data.get("VIDEO_ENC_PARAMS"), + ) + assert video_enc_params is not None + assert video_enc_params.nb_blocks == 40 * 24 + + first_block = video_enc_params.block_params(0) + assert video_enc_params.qp + first_block.delta_qp == 29 + return + + def test_decoded_video_enc_params_no_flag(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + stream = container.streams.video[0] + # When no additional flag is given, there should be no side data with the video encoding params + + for frame in container.decode(stream): + video_enc_params = frame.side_data.get("VIDEO_ENC_PARAMS") + assert video_enc_params is None + + def test_decode_video_corrupt(self) -> None: + # write an empty file + path = self.sandboxed("empty.h264") + with open(path, "wb"): + pass + + packet_count = 0 + frame_count = 0 + + with av.open(path) as container: + for packet in container.demux(video=0): + for frame in packet.decode(): + frame_count += 1 + packet_count += 1 + + assert packet_count == 1 + assert frame_count == 0 + + def test_decode_close_then_use(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + container.close() + + # Check accessing every attribute either works or raises + # an `AssertionError`. + for attr in dir(container): + with self.subTest(attr=attr): + try: + getattr(container, attr) + except AssertionError: + pass + + def test_flush_decoded_video_frame_count(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + video_stream = container.streams.video[0] + + # Decode the first GOP, which requires a flush to get all frames + have_keyframe = False + input_count = 0 + output_count = 0 + + for packet in container.demux(video_stream): + if packet.is_keyframe: + if have_keyframe: + break + have_keyframe = True + + input_count += 1 + + for frame in video_stream.decode(packet): + output_count += 1 + + # Check the test works as expected and requires a flush + assert output_count < input_count + + for frame in video_stream.decode(None): + # The Frame._time_base is not set by PyAV + assert frame.time_base is None + output_count += 1 + + assert output_count == input_count + + def test_no_side_data(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + frame = next(container.decode(video=0)) + assert frame.rotation == 0 + + def test_side_data(self) -> None: + container = av.open(fate_suite("mov/displaymatrix.mov")) + frame = next(container.decode(video=0)) + assert frame.rotation == -90 + + def test_hardware_decode(self) -> None: + hwdevices_available = av.codec.hwaccel.hwdevices_available() + if "HWACCEL_DEVICE_TYPE" not in os.environ: + pytest.skip( + "Set the HWACCEL_DEVICE_TYPE to run this test. " + f"Options are {' '.join(hwdevices_available)}" + ) + + HWACCEL_DEVICE_TYPE = os.environ["HWACCEL_DEVICE_TYPE"] + assert HWACCEL_DEVICE_TYPE in hwdevices_available, ( + f"{HWACCEL_DEVICE_TYPE} not available" + ) + + test_video_path = "tests/assets/black.mp4" + make_h264_test_video(test_video_path) + + hwaccel = av.codec.hwaccel.HWAccel( + device_type=HWACCEL_DEVICE_TYPE, allow_software_fallback=False + ) + + container = av.open(test_video_path, hwaccel=hwaccel) + video_stream = container.streams.video[0] + assert video_stream.codec_context.is_hwaccel + + frame_count = 0 + for frame in container.decode(video_stream): + frame_count += 1 + + assert frame_count == video_stream.frames + + +@pytest.mark.parametrize("is_hw_owned", [False, True]) +def test_hardware_decode_download_preserves_frame_props(is_hw_owned: bool) -> None: + hwdevices_available = av.codec.hwaccel.hwdevices_available() + if "HWACCEL_DEVICE_TYPE" not in os.environ: + pytest.skip( + "Set the HWACCEL_DEVICE_TYPE to run this test. " + f"Options are {' '.join(hwdevices_available)}" + ) + + hwaccel_device_type = os.environ["HWACCEL_DEVICE_TYPE"] + assert hwaccel_device_type in hwdevices_available, ( + f"{hwaccel_device_type} not available" + ) + + test_video_path = fate_suite("hevc/hdr10_plus_h265_sample.hevc") + cpu_frame = decode_first_video_frame(test_video_path) + hw_frame = decode_first_video_frame( + test_video_path, + av.codec.hwaccel.HWAccel( + device_type=hwaccel_device_type, + is_hw_owned=is_hw_owned, + allow_software_fallback=False, + ), + ) + + # Ensure that hardware decoding preserves the frame properties, see #2231 + assert_video_frame_color_props_match(hw_frame, cpu_frame) + + # Ensure that reformatting also preserves them, even for hardware frames + cpu_frame = cpu_frame.reformat(format="bgr24") + hw_frame = hw_frame.reformat(format="bgr24") + assert_video_frame_color_props_match(hw_frame, cpu_frame) + + +def test_hardware_frame_reformat_matches_downloaded_frame() -> None: + hwdevices_available = av.codec.hwaccel.hwdevices_available() + if "HWACCEL_DEVICE_TYPE" not in os.environ: + pytest.skip( + "Set the HWACCEL_DEVICE_TYPE to run this test. " + f"Options are {' '.join(hwdevices_available)}" + ) + + hwaccel_device_type = os.environ["HWACCEL_DEVICE_TYPE"] + assert hwaccel_device_type in hwdevices_available, ( + f"{hwaccel_device_type} not available" + ) + + test_video_path = fate_suite("h264/interlaced_crop.mp4") + downloaded_frame = decode_first_video_frame( + test_video_path, + av.codec.hwaccel.HWAccel( + device_type=hwaccel_device_type, + is_hw_owned=False, + allow_software_fallback=False, + ), + ) + hw_frame = decode_first_video_frame( + test_video_path, + av.codec.hwaccel.HWAccel( + device_type=hwaccel_device_type, + is_hw_owned=True, + allow_software_fallback=False, + ), + ) + assert downloaded_frame.format.name != hw_frame.format.name # E.g. cuda vs nv12 + + # Download hw_frame to CPU and ensure the contents match downloaded_frame + sw_format = downloaded_frame.format.name + reformatted_frame = hw_frame.reformat(format=sw_format) + assert reformatted_frame.format.name == downloaded_frame.format.name + assert np.array_equal( + reformatted_frame.to_ndarray(format=sw_format), + downloaded_frame.to_ndarray(format=sw_format), + ) + + +def decode_first_video_frame( + path: str, hwaccel: av.codec.hwaccel.HWAccel | None = None +) -> av.VideoFrame: + with av.open(path, hwaccel=hwaccel) as container: + for packet in container.demux(video=0): + frames = packet.decode() + if frames: + return frames[0] + raise AssertionError("expected at least one decoded frame") + + +def assert_video_frame_color_props_match( + actual: av.VideoFrame, expected: av.VideoFrame +) -> None: + assert actual.color_range == expected.color_range + assert actual.colorspace == expected.colorspace + assert actual.color_primaries == expected.color_primaries + assert actual.color_trc == expected.color_trc diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py deleted file mode 100644 index 30270146c..000000000 --- a/tests/test_deprecation.py +++ /dev/null @@ -1,51 +0,0 @@ -import warnings - -from av import deprecation - -from .common import TestCase - - -class TestDeprecations(TestCase): - - def test_method(self): - - class Example(object): - - def __init__(self, x=100): - self.x = x - - @deprecation.method - def foo(self, a, b): - return self.x + a + b - - obj = Example() - - with warnings.catch_warnings(record=True) as captured: - self.assertEqual(obj.foo(20, b=3), 123) - self.assertIn('Example.foo is deprecated', captured[0].message.args[0]) - - def test_renamed_attr(self): - - class Example(object): - - new_value = 'foo' - old_value = deprecation.renamed_attr('new_value') - - def new_func(self, a, b): - return a + b - - old_func = deprecation.renamed_attr('new_func') - - obj = Example() - - with warnings.catch_warnings(record=True) as captured: - - self.assertEqual(obj.old_value, 'foo') - self.assertIn('Example.old_value is deprecated', captured[0].message.args[0]) - - obj.old_value = 'bar' - self.assertIn('Example.old_value is deprecated', captured[1].message.args[0]) - - with warnings.catch_warnings(record=True) as captured: - self.assertEqual(obj.old_func(1, 2), 3) - self.assertIn('Example.old_func is deprecated', captured[0].message.args[0]) diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 000000000..4fffaf697 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,66 @@ +import sys + +import pytest + +import av.error +from av.device import DeviceInfo, enumerate_input_devices, enumerate_output_devices + + +def test_device_info_attributes() -> None: + d = DeviceInfo("0", "FaceTime HD Camera", True, ["video"]) + assert d.name == "0" + assert d.description == "FaceTime HD Camera" + assert d.is_default is True + assert d.media_types == ["video"] + + +def test_device_info_repr_default() -> None: + d = DeviceInfo("0", "FaceTime HD Camera", True, ["video"]) + assert repr(d) == "" + + +def test_device_info_repr_non_default() -> None: + d = DeviceInfo("1", "Built-in Microphone", False, ["audio"]) + assert repr(d) == "" + + +def test_enumerate_input_devices_unknown_format() -> None: + with pytest.raises(ValueError, match="no such input format"): + enumerate_input_devices("not_a_real_format_xyz") + + +def test_enumerate_output_devices_unknown_format() -> None: + with pytest.raises(ValueError, match="no such output format"): + enumerate_output_devices("not_a_real_format_xyz") + + +def _assert_valid_device_list(devices: list[DeviceInfo]) -> None: + assert isinstance(devices, list) + for device in devices: + assert isinstance(device, DeviceInfo) + assert isinstance(device.name, str) + assert isinstance(device.description, str) + assert isinstance(device.is_default, bool) + assert isinstance(device.media_types, list) + assert all(isinstance(mt, str) for mt in device.media_types) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="avfoundation is macOS only") +def test_enumerate_input_devices_avfoundation() -> None: + _assert_valid_device_list(enumerate_input_devices("avfoundation")) + + +@pytest.mark.skipif(sys.platform != "linux", reason="v4l2 is Linux only") +def test_enumerate_input_devices_v4l2() -> None: + try: + _assert_valid_device_list(enumerate_input_devices("video4linux2")) + except av.error.OSError: + pytest.skip("v4l2 device enumeration not available") + + +@pytest.mark.skipif(sys.platform != "win32", reason="dshow is Windows only") +def test_enumerate_input_devices_dshow() -> None: + try: + _assert_valid_device_list(enumerate_input_devices("dshow")) + except av.error.OSError: + pytest.skip("dshow device enumeration not available") diff --git a/tests/test_dictionary.py b/tests/test_dictionary.py index e27173e28..14e698d73 100644 --- a/tests/test_dictionary.py +++ b/tests/test_dictionary.py @@ -1,20 +1,26 @@ +import pytest + from av.dictionary import Dictionary -from .common import TestCase +def test_dictionary() -> None: + d = Dictionary() + d["key"] = "value" -class TestDictionary(TestCase): + assert d["key"] == "value" + assert "key" in d + assert len(d) == 1 + assert list(d) == ["key"] - def test_basics(self): + assert d.pop("key") == "value" + pytest.raises(KeyError, d.pop, "key") + assert len(d) == 0 - d = Dictionary() - d['key'] = 'value' - self.assertEqual(d['key'], 'value') - self.assertIn('key', d) - self.assertEqual(len(d), 1) - self.assertEqual(list(d), ['key']) +def test_dictionary_non_ascii() -> None: + d = Dictionary() + d["café"] = "naïve 日本語 🎵" - self.assertEqual(d.pop('key'), 'value') - self.assertRaises(KeyError, d.pop, 'key') - self.assertEqual(len(d), 0) + assert d["café"] == "naïve 日本語 🎵" + assert "café" in d + assert list(d) == ["café"] diff --git a/tests/test_display_matrix.py b/tests/test_display_matrix.py new file mode 100644 index 000000000..25c02af6c --- /dev/null +++ b/tests/test_display_matrix.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import io +import struct +from typing import cast + +import numpy as np +import pytest + +import av +from av.sidedata.sidedata import SideData +from av.video.stream import VideoStream + +WIDTH = 320 +HEIGHT = 240 +DURATION = 10 + +# The 8 EXIF orientations as 3x3 transformation matrices, built the same way as +# the application code: a 90 deg rotation generator (R) and a horizontal-flip +# generator (F). Orientations 2, 4, 5, 7 are reflections, which a scalar +# rotation cannot represent -- so these are verified by comparing the full +# matrix that round-trips through the container. +_R = np.asarray([[0, -1, 0], [1, 0, 0], [0, 0, 1]], dtype=float) # exif 8 +_F = np.asarray([[-1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=float) # exif 2 + +EXIF_MATRICES = { + 1: np.eye(3), + 2: _F, + 3: _R @ _R, + 4: _F @ _R @ _R, + 5: _F @ _R @ _R @ _R, + 6: _R @ _R @ _R, + 7: _F @ _R, + 8: _R, +} + +# Pure-rotation orientations also have a well-defined scalar rotation, reported +# by av_display_rotation_get() (counter-clockwise, range [-180, 180]). +EXPECTED_ROTATION = {1: 0, 3: 180, 6: -90, 8: 90} + +# Each EXIF orientation expressed through the convenience API as +# (degrees_ccw, hflip, vflip). Verified to reproduce EXIF_MATRICES exactly. +EXIF_VIA_ROTATION = { + 1: (0, False, False), + 2: (0, True, False), + 3: (0, True, True), + 4: (0, False, True), + 5: (90, True, False), + 6: (90, True, True), + 7: (90, False, True), + 8: (90, False, False), +} + +# One encoder per codec family we care about, plus the near-universal mpeg4 +# baseline. Unavailable encoders are skipped at runtime so the suite stays +# portable across FFmpeg builds. +CODECS = ["mpeg4", "libx264", "libopenh264", "libx265", "libsvtav1", "libaom-av1"] + + +def matrix_to_ints(matrix: np.ndarray) -> list[int]: + """Convert a 3x3 matrix to FFmpeg's AV_PKT_DATA_DISPLAYMATRIX integers. + + Layout (a, b, u, c, d, v, x, y, w): 16.16 fixed point everywhere except + u, v, w (indices 2, 5, 8) which are 2.30. + """ + flat = [float(v) for v in matrix.reshape(-1)] + return [ + int(round(v * (1 << 30))) if i in (2, 5, 8) else int(round(v * (1 << 16))) + for i, v in enumerate(flat) + ] + + +def _has_encoder(name: str) -> bool: + try: + av.codec.Codec(name, "w") + except Exception: + return False + return True + + +def _encode(codec_name: str, matrix: list[int] | None) -> io.BytesIO: + buf = io.BytesIO() + container = av.open(buf, "w", format="mp4") + stream = cast(VideoStream, container.add_stream(codec_name, rate=24)) + stream.width = WIDTH + stream.height = HEIGHT + stream.pix_fmt = "yuv420p" + + if matrix is not None: + stream.set_display_matrix(matrix) + + for i in range(DURATION): + img = np.full((HEIGHT, WIDTH, 3), (i * 8) % 256, dtype=np.uint8) + frame = av.VideoFrame.from_ndarray(img, format="rgb24") + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + container.close() + + buf.seek(0) + return buf + + +def _read_frame(buf: io.BytesIO) -> av.VideoFrame: + with av.open(buf, "r", format="mp4") as container: + return next(container.decode(video=0)) + + +def _read_matrix(frame: av.VideoFrame) -> list[int] | None: + sd = frame.side_data.get("DISPLAYMATRIX") + if sd is None: + return None + return list(struct.unpack("=9i", bytes(cast(SideData, sd)))) + + +@pytest.mark.parametrize("codec_name", CODECS) +@pytest.mark.parametrize("orientation", sorted(EXIF_MATRICES)) +def test_exif_orientation_roundtrip(codec_name: str, orientation: int) -> None: + if not _has_encoder(codec_name): + pytest.skip(f"encoder {codec_name} not available") + + expected = matrix_to_ints(EXIF_MATRICES[orientation]) + frame = _read_frame(_encode(codec_name, expected)) + got = _read_matrix(frame) + + identity = matrix_to_ints(np.eye(3)) + if expected == identity: + # Identity is the container default; demuxers emit no side data for it. + assert got is None + assert frame.rotation == 0 + else: + assert got == expected, f"exif {orientation}: wrote {expected}, read {got}" + + if orientation in EXPECTED_ROTATION: + rotation = frame.rotation + # 180 may come back negated; rotations are exact otherwise. + if abs(EXPECTED_ROTATION[orientation]) == 180: + assert abs(rotation) == 180 + else: + assert rotation == EXPECTED_ROTATION[orientation] + + +@pytest.mark.parametrize("degrees,expected", [(0, 0), (90, 90), (180, 180), (270, -90)]) +def test_set_display_rotation_roundtrip(degrees: int, expected: int) -> None: + # The public angle is counter-clockwise, matching VideoFrame.rotation. + buf = io.BytesIO() + container = av.open(buf, "w", format="mp4") + stream = container.add_stream("mpeg4", rate=24) + stream.width = WIDTH + stream.height = HEIGHT + stream.pix_fmt = "yuv420p" + stream.set_display_rotation(degrees) + for i in range(DURATION): + frame = av.VideoFrame.from_ndarray( + np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8), format="rgb24" + ) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + container.close() + + buf.seek(0) + rotation = _read_frame(buf).rotation + if abs(expected) == 180: + assert abs(rotation) == 180 + else: + assert rotation == expected + + +@pytest.mark.parametrize("orientation", sorted(EXIF_VIA_ROTATION)) +def test_convenience_reaches_all_exif_orientations(orientation: int) -> None: + # set_display_rotation(degrees, hflip, vflip) must reproduce the exact same + # matrix as the explicit EXIF table for every one of the 8 orientations. + degrees, hflip, vflip = EXIF_VIA_ROTATION[orientation] + expected = matrix_to_ints(EXIF_MATRICES[orientation]) + + buf = io.BytesIO() + container = av.open(buf, "w", format="mp4") + stream = container.add_stream("mpeg4", rate=24) + stream.width = WIDTH + stream.height = HEIGHT + stream.pix_fmt = "yuv420p" + stream.set_display_rotation(degrees, hflip=hflip, vflip=vflip) + for i in range(DURATION): + frame = av.VideoFrame.from_ndarray( + np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8), format="rgb24" + ) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + container.close() + + buf.seek(0) + got = _read_matrix(_read_frame(buf)) + if expected == matrix_to_ints(np.eye(3)): + assert got is None # identity emits no side data + else: + assert got == expected, f"exif {orientation}: wrote {expected}, read {got}" + + +def test_matrix_and_rotation_setters_are_mutually_exclusive() -> None: + # Setting one path must clear the other so they don't both apply. + buf = io.BytesIO() + with av.open(buf, "w", format="mp4") as container: + stream = container.add_stream("mpeg4", rate=24) + stream.width = WIDTH + stream.height = HEIGHT + stream.pix_fmt = "yuv420p" + stream.set_display_rotation(90) + stream.set_display_matrix(None) # clears both paths + frame = av.VideoFrame.from_ndarray( + np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8), format="rgb24" + ) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + + buf.seek(0) + assert _read_matrix(_read_frame(buf)) is None + + +def test_set_display_matrix_validates_length() -> None: + buf = io.BytesIO() + with av.open(buf, "w", format="mp4") as container: + stream = container.add_stream("mpeg4", rate=24) + with pytest.raises(ValueError): + stream.set_display_matrix([0, 1, 2]) + + +def test_set_display_matrix_none_clears() -> None: + buf = io.BytesIO() + with av.open(buf, "w", format="mp4") as container: + stream = container.add_stream("mpeg4", rate=24) + stream.set_display_matrix(matrix_to_ints(EXIF_MATRICES[6])) + stream.set_display_matrix(None) # clear before encoding + stream.width = WIDTH + stream.height = HEIGHT + stream.pix_fmt = "yuv420p" + frame = av.VideoFrame.from_ndarray( + np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8), format="rgb24" + ) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + + buf.seek(0) + assert _read_matrix(_read_frame(buf)) is None diff --git a/tests/test_dlpack.py b/tests/test_dlpack.py new file mode 100644 index 000000000..b02675525 --- /dev/null +++ b/tests/test_dlpack.py @@ -0,0 +1,751 @@ +import gc +from fractions import Fraction + +import numpy +import pytest + +import av +from av import VideoFrame +from av.codec.hwaccel import HWAccel + +from .common import assertNdarraysEqual, fate_png + + +def _make_u8(shape: tuple[int, ...]) -> numpy.ndarray: + return numpy.arange(int(numpy.prod(shape)), dtype=numpy.uint8).reshape(shape) + + +def _make_u16(shape: tuple[int, ...]) -> numpy.ndarray: + return numpy.arange(int(numpy.prod(shape)), dtype=numpy.uint16).reshape(shape) + + +def _plane_to_2d(plane, height: int, width: int, dtype) -> numpy.ndarray: + itemsize = numpy.dtype(dtype).itemsize + assert plane.line_size % itemsize == 0 + pitch_elems = plane.line_size // itemsize + arr = numpy.frombuffer(memoryview(plane), dtype=dtype).reshape(height, pitch_elems) + return arr[:, :width] + + +def _get_cuda_backend(): + try: + import cupy # type: ignore + + try: + if cupy.cuda.runtime.getDeviceCount() > 0: + return ("cupy", cupy) + except Exception: + pass + except Exception: + pass + + try: + import torch # type: ignore + + if torch.cuda.is_available(): + return ("torch", torch) + except Exception: + pass + + return None + + +def test_hwaccel_validation_and_primary_ctx() -> None: + hw = HWAccel(device_type="cuda") + assert hw.is_hw_owned == False + assert "primary_ctx" not in hw.options + + hw = HWAccel(device_type="cuda", is_hw_owned=True) + assert hw.is_hw_owned == True + assert hw.options.get("primary_ctx") == "1" + + hw = HWAccel(device_type="cuda", is_hw_owned=True, options={"primary_ctx": "0"}) + assert hw.options.get("primary_ctx") == "0" + + +def test_video_frame_from_dlpack_nv12_cpu_basic_zero_copy_and_lifetime() -> None: + width, height = 64, 48 + y = _make_u8((height, width)) + uv = _make_u8((height // 2, width // 2, 2)) + + frame = VideoFrame.from_dlpack((y, uv), format="nv12") + + assert frame.format.name == "nv12" + assert frame.width == width + assert frame.height == height + assert len(frame.planes) == 2 + assert frame.planes[0].width == width + assert frame.planes[0].height == height + assert frame.planes[1].width == width // 2 + assert frame.planes[1].height == height // 2 + assert frame.planes[0].line_size == width + assert frame.planes[1].line_size == width + + y_plane = _plane_to_2d(frame.planes[0], height, width, numpy.uint8) + uv_plane = _plane_to_2d(frame.planes[1], height // 2, width, numpy.uint8) + assertNdarraysEqual(y_plane, y) + assertNdarraysEqual(uv_plane, uv.reshape(height // 2, width)) + + y[0, 0] = 123 + uv[0, 0, 0] = 11 + uv[0, 0, 1] = 22 + + expected_y_bytes = y.tobytes() + expected_uv_bytes = uv.reshape(height // 2, width).tobytes() + + assert memoryview(frame.planes[0])[0] == 123 + assert memoryview(frame.planes[1])[0] == 11 + assert memoryview(frame.planes[1])[1] == 22 + + del y + del uv + gc.collect() + + assert bytes(frame.planes[0]) == expected_y_bytes + assert bytes(frame.planes[1]) == expected_uv_bytes + + +def test_video_frame_from_dlpack_nv12_cpu_with_pitch_and_dlpack_export() -> None: + width, height = 64, 48 + pad = 16 + + y_base = _make_u8((height, width + pad)) + y = y_base[:, :width] + uv_base = _make_u8((height // 2, (width + pad) // 2, 2)) + uv = uv_base[:, : width // 2, :] + + frame = VideoFrame.from_dlpack((y, uv), format="nv12") + + assert frame.planes[0].line_size == width + pad + assert frame.planes[1].line_size == width + pad + assert frame.planes[0].buffer_size == (width + pad) * height + assert frame.planes[1].buffer_size == (width + pad) * (height // 2) + + y_plane = _plane_to_2d(frame.planes[0], height, width, numpy.uint8) + uv_plane = _plane_to_2d(frame.planes[1], height // 2, width, numpy.uint8) + assertNdarraysEqual(y_plane, y) + assertNdarraysEqual(uv_plane, uv.reshape(height // 2, width)) + + assert frame.planes[0].__dlpack_device__() == (1, 0) + + y_dl = numpy.from_dlpack(frame.planes[0]) + uv_dl = numpy.from_dlpack(frame.planes[1]) + + assert y_dl.shape == (height, width) + assert y_dl.dtype == numpy.uint8 + assert y_dl.strides == (width + pad, 1) + assertNdarraysEqual(y_dl, y) + + assert uv_dl.shape == (height // 2, width // 2, 2) + assert uv_dl.dtype == numpy.uint8 + assert uv_dl.strides == (width + pad, 2, 1) + assertNdarraysEqual(uv_dl, uv) + + expected_y = numpy.array(y, copy=True) + expected_uv = numpy.array(uv, copy=True) + + del frame + del y + del uv + del y_base + del uv_base + gc.collect() + + assertNdarraysEqual(y_dl, expected_y) + assertNdarraysEqual(uv_dl, expected_uv) + + +def test_video_frame_from_dlpack_nv12_cpu_accepts_uv_2d() -> None: + width, height = 64, 48 + y = _make_u8((height, width)) + uv2d = _make_u8((height // 2, width)) + + frame = VideoFrame.from_dlpack((y, uv2d), format="nv12") + + uv_plane = _plane_to_2d(frame.planes[1], height // 2, width, numpy.uint8) + assertNdarraysEqual(uv_plane, uv2d) + + uv_dl = numpy.from_dlpack(frame.planes[1]) + assert uv_dl.shape == (height // 2, width // 2, 2) + assertNdarraysEqual(uv_dl, uv2d.reshape(height // 2, width // 2, 2)) + + +def test_video_frame_from_dlpack_accepts_video_plane_objects() -> None: + width, height = 64, 48 + y = _make_u8((height, width)) + uv = _make_u8((height // 2, width // 2, 2)) + + frame1 = VideoFrame.from_dlpack((y, uv), format="nv12") + frame2 = VideoFrame.from_dlpack((frame1.planes[0], frame1.planes[1]), format="nv12") + + assert bytes(frame2.planes[0]) == bytes(frame1.planes[0]) + assert bytes(frame2.planes[1]) == bytes(frame1.planes[1]) + + +@pytest.mark.parametrize("fmt", ["p010le", "p016le"]) +def test_video_frame_from_dlpack_p010_p016_cpu(fmt: str) -> None: + width, height = 64, 48 + y = _make_u16((height, width)) + uv = _make_u16((height // 2, width // 2, 2)) + + frame = VideoFrame.from_dlpack((y, uv), format=fmt) + + assert frame.format.name == fmt + assert len(frame.planes) == 2 + assert frame.planes[0].line_size == width * 2 + assert frame.planes[1].line_size == width * 2 + + y_plane = _plane_to_2d(frame.planes[0], height, width, numpy.uint16) + uv_plane = _plane_to_2d(frame.planes[1], height // 2, width, numpy.uint16) + assertNdarraysEqual(y_plane, y) + assertNdarraysEqual(uv_plane, uv.reshape(height // 2, width)) + + y_dl = numpy.from_dlpack(frame.planes[0]) + uv_dl = numpy.from_dlpack(frame.planes[1]) + + assert y_dl.dtype == numpy.uint16 + assert y_dl.shape == (height, width) + assert y_dl.strides == (width * 2, 2) + assertNdarraysEqual(y_dl, y) + + assert uv_dl.dtype == numpy.uint16 + assert uv_dl.shape == (height // 2, width // 2, 2) + assert uv_dl.strides == (width * 2, 4, 2) + assertNdarraysEqual(uv_dl, uv) + + +def test_video_plane_dlpack_export_keeps_frame_alive_after_gc() -> None: + container = av.open(fate_png()) + frame = next(container.decode(video=0)) + frame_nv12 = frame.reformat(format="nv12") + + width = frame_nv12.width + height = frame_nv12.height + line_size = frame_nv12.planes[0].line_size + expected = _plane_to_2d(frame_nv12.planes[0], height, width, numpy.uint8).copy() + + y_dl = numpy.from_dlpack(frame_nv12.planes[0]) + assert y_dl.shape == (height, width) + assert y_dl.strides == (line_size, 1) + + del frame_nv12 + del frame + del container + gc.collect() + + assertNdarraysEqual(y_dl, expected) + + +def test_video_plane_dlpack_export_packed_rgb_cpu() -> None: + # Packed formats interleave several components in one plane and export as + # a 3D (H, W, C) tensor (issue #2217). + rgb = (numpy.arange(16 * 24 * 3) % 251).astype(numpy.uint8).reshape(16, 24, 3) + frame = VideoFrame.from_ndarray(rgb, format="rgb24") + plane = frame.planes[0] + assert plane.__dlpack_device__() == (1, 0) + + arr = numpy.from_dlpack(plane) + assert arr.shape == (16, 24, 3) + assert arr.strides == (plane.line_size, 3, 1) + assert arr.dtype == numpy.uint8 + assertNdarraysEqual(arr, rgb) + + +def test_video_plane_dlpack_export_planar_yuv_cpu() -> None: + # Planar formats expose each single-component plane as a 2D (H, W) tensor + # (issue #2217). + frame = VideoFrame(16, 16, "yuv420p") + for index, (h, w) in enumerate([(16, 16), (8, 8), (8, 8)]): + plane = frame.planes[index] + assert plane.__dlpack_device__() == (1, 0) + arr = numpy.from_dlpack(plane) + assert arr.shape == (h, w) + assert arr.strides == (plane.line_size, 1) + assert arr.dtype == numpy.uint8 + + +def test_video_plane_dlpack_export_planar_yuv16_cpu() -> None: + # 16-bit planar formats export as uint16. + frame = VideoFrame(16, 16, "yuv420p10le") + arr = numpy.from_dlpack(frame.planes[0]) + assert arr.shape == (16, 16) + assert arr.dtype == numpy.uint16 + + +def test_video_plane_dlpack_unsupported_format_raises() -> None: + # Palette formats still cannot be exported. + frame = VideoFrame(16, 16, "pal8") + assert frame.planes[0].__dlpack_device__() == (1, 0) + + with pytest.raises( + NotImplementedError, + match="bitstream, palette, and Bayer formats are not supported", + ): + frame.planes[0].__dlpack__() + + +def test_sw_format_none_for_software_frame() -> None: + assert VideoFrame(16, 16, "yuv420p").sw_format is None + + +def test_video_frame_from_dlpack_requires_two_planes() -> None: + y = numpy.zeros((4, 4), dtype=numpy.uint8) + with pytest.raises(ValueError, match="2-plane"): + VideoFrame.from_dlpack(y, format="nv12") + + +def test_video_frame_from_dlpack_rejects_packed_format() -> None: + # Packed formats interleave several components in one plane and cannot be + # imported (only planar single-component formats and the nv12 family are). + rgb = numpy.zeros((16, 16, 3), dtype=numpy.uint8) + + with pytest.raises(NotImplementedError, match="is not supported"): + VideoFrame.from_dlpack((rgb,), format="rgb24") + + +@pytest.mark.parametrize( + "fmt,dtype,planes_hw", + [ + ("yuv420p", numpy.uint8, [(48, 64), (24, 32), (24, 32)]), + ("yuv422p", numpy.uint8, [(48, 64), (48, 32), (48, 32)]), + ("yuv444p", numpy.uint8, [(48, 64), (48, 64), (48, 64)]), + ("gray", numpy.uint8, [(48, 64)]), + ("yuv420p10le", numpy.uint16, [(48, 64), (24, 32), (24, 32)]), + ], +) +def test_video_frame_from_dlpack_planar_cpu(fmt, dtype, planes_hw) -> None: + # Issue #2217: planar formats whose planes each hold one component round + # trip through DLPack without a NumPy intermediate. + width, height = 64, 48 + make = _make_u16 if dtype == numpy.uint16 else _make_u8 + src = [make((h, w)) for (h, w) in planes_hw] + + frame = VideoFrame.from_dlpack(tuple(src), format=fmt) + + assert frame.format.name == fmt + assert frame.width == width and frame.height == height + assert len(frame.planes) == len(src) + + for i, plane in enumerate(frame.planes): + arr = numpy.from_dlpack(plane) + assert arr.dtype == dtype + assert arr.shape == planes_hw[i] + assertNdarraysEqual(arr, src[i]) + + +def test_video_frame_from_dlpack_yuv420p_zero_copy_and_lifetime() -> None: + width, height = 64, 48 + y = _make_u8((height, width)) + u = _make_u8((height // 2, width // 2)) + v = _make_u8((height // 2, width // 2)) + + frame = VideoFrame.from_dlpack((y, u, v), format="yuv420p") + + # Mutating the source is visible through the frame (zero copy). + y[0, 0] = 200 + assert memoryview(frame.planes[0])[0] == 200 + + expected = [y.copy(), u.copy(), v.copy()] + del y, u, v + gc.collect() + + for i, plane in enumerate(frame.planes): + assertNdarraysEqual(numpy.from_dlpack(plane), expected[i]) + + +def test_video_frame_from_dlpack_yuv420p_with_pitch() -> None: + width, height = 64, 48 + pad = 16 + + y = _make_u8((height, width + pad))[:, :width] + u = _make_u8((height // 2, (width + pad) // 2))[:, : width // 2] + v = _make_u8((height // 2, (width + pad) // 2))[:, : width // 2] + + frame = VideoFrame.from_dlpack((y, u, v), format="yuv420p") + + assert frame.planes[0].line_size == width + pad + assert frame.planes[1].line_size == (width + pad) // 2 + assertNdarraysEqual(numpy.from_dlpack(frame.planes[0]), y) + assertNdarraysEqual(numpy.from_dlpack(frame.planes[1]), u) + assertNdarraysEqual(numpy.from_dlpack(frame.planes[2]), v) + + +def test_video_frame_from_dlpack_planar_wrong_plane_count() -> None: + y = numpy.zeros((48, 64), dtype=numpy.uint8) + u = numpy.zeros((24, 32), dtype=numpy.uint8) + + with pytest.raises(ValueError, match=r"requires 3 plane\(s\), got 2"): + VideoFrame.from_dlpack((y, u), format="yuv420p") + + +def test_video_frame_from_dlpack_planar_rejects_odd_dimensions() -> None: + y = numpy.zeros((48, 63), dtype=numpy.uint8) + u = numpy.zeros((24, 32), dtype=numpy.uint8) + v = numpy.zeros((24, 32), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="must be even"): + VideoFrame.from_dlpack((y, u, v), format="yuv420p") + + +def test_video_frame_from_dlpack_planar_rejects_palette() -> None: + idx = numpy.zeros((16, 16), dtype=numpy.uint8) + + with pytest.raises(NotImplementedError, match="palette"): + VideoFrame.from_dlpack((idx,), format="pal8") + + +def test_video_frame_from_dlpack_rejects_device_id_for_cpu() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="device_id must be 0 for CPU tensors"): + VideoFrame.from_dlpack((y, uv), format="nv12", device_id=1) + + +def test_video_frame_from_dlpack_requires_both_width_height_or_neither() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="either specify both width/height or neither"): + VideoFrame.from_dlpack((y, uv), format="nv12", width=width, height=0) + + +def test_video_frame_from_dlpack_rejects_plane0_shape_mismatch_with_width_height() -> ( + None +): + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="plane 0 shape does not match width/height"): + VideoFrame.from_dlpack((y, uv), format="nv12", width=width + 2, height=height) + + +def test_video_frame_from_dlpack_rejects_odd_dimensions() -> None: + width, height = 63, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="width/height must be even"): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_rejects_noncontiguous_plane0_last_dim() -> None: + width, height = 64, 48 + y_full = numpy.zeros((height, width * 2), dtype=numpy.uint8) + y = y_full[:, ::2] + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + with pytest.raises( + ValueError, match="plane 0 must be contiguous in the last dimension" + ): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_rejects_noncontiguous_uv_plane_last_dim_2d() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv_full = numpy.zeros((height // 2, width * 2), dtype=numpy.uint8) + uv = uv_full[:, ::2] + + with pytest.raises( + ValueError, match="plane 1 must be contiguous in the last dimension" + ): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_rejects_unexpected_uv_strides_3d() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv_full = numpy.zeros((height // 2, width // 2, 4), dtype=numpy.uint8) + uv = uv_full[:, :, :2] + + with pytest.raises(ValueError, match="unexpected UV plane strides"): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_rejects_wrong_dtype_plane0() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint16) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + with pytest.raises(TypeError, match="unexpected dtype for plane 0"): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_rejects_wrong_dtype_plane1() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint16) + + with pytest.raises(TypeError, match="unexpected dtype for plane 1"): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_p010le_requires_uint16() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + with pytest.raises(TypeError, match="unexpected dtype for plane 0"): + VideoFrame.from_dlpack((y, uv), format="p010le") + + +def test_video_frame_from_dlpack_rejects_plane0_ndim_not_2() -> None: + y = numpy.zeros((4, 4, 1), dtype=numpy.uint8) + uv = numpy.zeros((2, 4), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="plane 0 must be 2D"): + VideoFrame.from_dlpack((y, uv), format="nv12", width=4, height=4) + + +def test_video_frame_from_dlpack_rejects_plane1_ndim_not_2_or_3() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width, 1, 1), dtype=numpy.uint8) + + with pytest.raises(ValueError, match="plane 1 must be 2D or 3D"): + VideoFrame.from_dlpack((y, uv), format="nv12") + + +def test_video_frame_from_dlpack_reusing_capsule_raises_typeerror() -> None: + width, height = 64, 48 + y = numpy.zeros((height, width), dtype=numpy.uint8) + uv = numpy.zeros((height // 2, width // 2, 2), dtype=numpy.uint8) + + cap0 = y.__dlpack__() + cap1 = uv.__dlpack__() + + VideoFrame.from_dlpack((cap0, cap1), format="nv12", width=width, height=height) + + with pytest.raises(TypeError, match="expected a DLPack capsule"): + VideoFrame.from_dlpack((cap0, cap1), format="nv12", width=width, height=height) + + +def test_video_frame_from_dlpack_invalid_plane_object_raises_typeerror() -> None: + with pytest.raises(TypeError, match="expected a DLPack capsule"): + VideoFrame.from_dlpack((object(), object()), format="nv12", width=64, height=48) + + +def test_cuda_context_flags() -> None: + default_ctx = av.video.frame.CudaContext() + assert default_ctx.primary_ctx is True + assert default_ctx.current_ctx is False + assert default_ctx.cuda_stream == 0 + + with pytest.raises(ValueError, match="mutually exclusive"): + av.video.frame.CudaContext(primary_ctx=True, current_ctx=True) + + ctx = av.video.frame.CudaContext( + primary_ctx=False, current_ctx=True, cuda_stream=1234 + ) + assert ctx.primary_ctx is False + assert ctx.current_ctx is True + assert ctx.cuda_stream == 1234 + + with pytest.raises(TypeError, match="integer or None"): + av.video.frame.CudaContext(cuda_stream="1234") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="non-negative"): + av.video.frame.CudaContext(cuda_stream=-1) + + with pytest.raises(ValueError, match="requires device_id 0.*CUDA_VISIBLE_DEVICES"): + av.video.frame.CudaContext(device_id=1, cuda_stream=1234) + + for default_stream in (None, 0): + nonzero_device_ctx = av.video.frame.CudaContext( + device_id=1, cuda_stream=default_stream + ) + assert nonzero_device_ctx.device_id == 1 + assert nonzero_device_ctx.cuda_stream == 0 + + +def test_video_frame_from_dlpack_cuda_hw_frame_behavior_if_available() -> None: + backend = _get_cuda_backend() + if backend is None: + pytest.skip("CUDA backend (cupy/torch) not available.") + + width, height = 64, 48 + name, mod = backend + + try: + if name == "cupy": + try: + ndev = int(mod.cuda.runtime.getDeviceCount()) + except Exception: + ndev = 1 + + device_id = 1 if ndev > 1 else 0 + with mod.cuda.Device(device_id): + y = mod.arange(height * width, dtype=mod.uint8).reshape(height, width) + uv = mod.arange( + (height // 2) * (width // 2) * 2, dtype=mod.uint8 + ).reshape(height // 2, width // 2, 2) + expected_device = y.__dlpack_device__() + frame = VideoFrame.from_dlpack((y, uv), format="nv12") + + assert frame.format.name == "cuda" + assert len(frame.planes) == 2 + + with pytest.raises( + TypeError, match="Hardware frame planes do not support" + ): + memoryview(frame.planes[0]) + + assert frame.planes[0].__dlpack_device__() == expected_device + + cap_y = frame.planes[0].__dlpack__() + if hasattr(mod, "fromDlpack"): + y2 = mod.fromDlpack(cap_y) + else: + y2 = mod.from_dlpack(cap_y) + + assert y2.shape == y.shape + assert mod.all(y2 == y).item() + + with pytest.raises( + ValueError, + match="Cannot convert a hardware frame to numpy directly", + ): + frame.to_ndarray(format="cuda") + + else: + try: + ndev = int(mod.cuda.device_count()) + except Exception: + ndev = 1 + + device_id = 1 if ndev > 1 else 0 + device = f"cuda:{device_id}" + + y = mod.arange(height * width, device=device, dtype=mod.uint8).reshape( + height, width + ) + uv = mod.arange( + (height // 2) * (width // 2) * 2, device=device, dtype=mod.uint8 + ).reshape(height // 2, width // 2, 2) + + expected_device = y.__dlpack_device__() + frame = VideoFrame.from_dlpack((y, uv), format="nv12") + + assert frame.format.name == "cuda" + assert len(frame.planes) == 2 + + with pytest.raises(TypeError, match="Hardware frame planes do not support"): + memoryview(frame.planes[0]) + + assert frame.planes[0].__dlpack_device__() == expected_device + + cap_y = frame.planes[0].__dlpack__() + y2 = mod.utils.dlpack.from_dlpack(cap_y) + + assert tuple(y2.shape) == tuple(y.shape) + assert mod.equal(y2, y) + + with pytest.raises( + ValueError, match="Cannot convert a hardware frame to numpy directly" + ): + frame.to_ndarray(format="cuda") + except av.FFmpegError as e: + pytest.skip(f"CUDA hwcontext not available in this build/runtime: {e}") + + +@pytest.mark.parametrize( + "use_current_ctx", [False, True], ids=["primary-context", "current-context"] +) +def test_encode_cuda_frame_with_nvenc_if_available(use_current_ctx: bool) -> None: + # Issue #2199: a CUDA frame from DLPack should encode on the GPU directly. + # Its hw_frames_ctx must propagate to the encoder before avcodec_open2. + backend = _get_cuda_backend() + if backend is None: + pytest.skip("CUDA backend (cupy/torch) not available.") + + name, mod = backend + width, height = 256, 256 + + try: + if name == "torch": + y = mod.zeros((height, width), dtype=mod.uint8, device="cuda") + uv = mod.zeros((height // 2, width // 2, 2), dtype=mod.uint8, device="cuda") + else: + y = mod.zeros((height, width), dtype=mod.uint8) + uv = mod.zeros((height // 2, width // 2, 2), dtype=mod.uint8) + + if use_current_ctx: + current_ctx = av.video.frame.CudaContext( + device_id=int(y.__dlpack_device__()[1]), + primary_ctx=False, + current_ctx=True, + ) + frame = VideoFrame.from_dlpack( + (y, uv), format="nv12", cuda_context=current_ctx + ) + else: + frame = VideoFrame.from_dlpack((y, uv), format="nv12") + assert frame.format.name == "cuda" + assert frame.sw_format is not None and frame.sw_format.name == "nv12" + + cc = av.CodecContext.create("h264_nvenc", "w") + cc.width = width + cc.height = height + cc.time_base = Fraction(1, 24) + cc.framerate = Fraction(24, 1) + cc.pix_fmt = "cuda" + + packets = cc.encode(frame) + packets += cc.encode(None) # flush + assert any(p.size for p in packets) + except av.FFmpegError as e: + pytest.skip(f"nvenc/CUDA not available in this build/runtime: {e}") + + +def test_encode_cuda_frame_with_nvenc_external_stream_if_available() -> None: + try: + import torch # type: ignore + except Exception: + pytest.skip("PyTorch is not available.") + if not torch.cuda.is_available(): + pytest.skip("CUDA is not available.") + + width, height = 256, 256 + with torch.cuda.device(0): + producer_stream = torch.cuda.Stream() + encoder_stream = torch.cuda.Stream() + encoder_stream_ptr = int(encoder_stream.cuda_stream) + + try: + with torch.cuda.device(0), torch.cuda.stream(producer_stream): + y = torch.zeros((height, width), dtype=torch.uint8, device="cuda:0") + uv = torch.zeros( + (height // 2, width // 2, 2), + dtype=torch.uint8, + device="cuda:0", + ) + cuda_context = av.video.frame.CudaContext( + device_id=int(y.__dlpack_device__()[1]), + primary_ctx=False, + current_ctx=True, + cuda_stream=encoder_stream_ptr, + ) + frame = VideoFrame.from_dlpack( + (y, uv), + format="nv12", + stream=encoder_stream_ptr, + cuda_context=cuda_context, + ) + + assert cuda_context.cuda_stream == encoder_stream_ptr + cc = av.CodecContext.create("h264_nvenc", "w") + cc.width = width + cc.height = height + cc.time_base = Fraction(1, 24) + cc.framerate = Fraction(24, 1) + cc.pix_fmt = "cuda" + + packets = cc.encode(frame) + packets += cc.encode(None) + assert any(p.size for p in packets) + except av.FFmpegError as e: + pytest.skip(f"nvenc/CUDA not available in this build/runtime: {e}") diff --git a/tests/test_doctests.py b/tests/test_doctests.py index 34ee969e4..63220889c 100644 --- a/tests/test_doctests.py +++ b/tests/test_doctests.py @@ -1,37 +1,36 @@ -from unittest import TestCase import doctest import pkgutil import re +from unittest import TestCase import av +import av.datasets def fix_doctests(suite): - for case in suite._tests: - # Add some more flags. case._dt_optionflags = ( - (case._dt_optionflags or 0) | - doctest.IGNORE_EXCEPTION_DETAIL | - doctest.ELLIPSIS | - doctest.NORMALIZE_WHITESPACE + (case._dt_optionflags or 0) + | doctest.IGNORE_EXCEPTION_DETAIL + | doctest.ELLIPSIS + | doctest.NORMALIZE_WHITESPACE ) - case._dt_test.globs['av'] = av - case._dt_test.globs['video_path'] = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4') + case._dt_test.globs["av"] = av + case._dt_test.globs["video_path"] = av.datasets.curated( + "pexels/time-lapse-video-of-night-sky-857195.mp4" + ) for example in case._dt_test.examples: - # Remove b prefix from strings. if example.want.startswith("b'"): example.want = example.want[1:] def register_doctests(mod): - if isinstance(mod, str): - mod = __import__(mod, fromlist=['']) + mod = __import__(mod, fromlist=[""]) try: suite = doctest.DocTestSuite(mod) @@ -40,13 +39,15 @@ def register_doctests(mod): fix_doctests(suite) - cls_name = 'Test' + ''.join(x.title() for x in mod.__name__.split('.')) - cls = type(cls_name, (TestCase, ), {}) + cls_name = "Test" + "".join(x.title() for x in mod.__name__.split(".")) + cls = type(cls_name, (TestCase,), {}) for test in suite._tests: + def func(self): return test.runTest() - name = str('test_' + re.sub('[^a-zA-Z0-9]+', '_', test.id()).strip('_')) + + name = str("test_" + re.sub("[^a-zA-Z0-9]+", "_", test.id()).strip("_")) func.__name__ = name setattr(cls, name, func) @@ -54,8 +55,6 @@ def func(self): for importer, mod_name, ispkg in pkgutil.walk_packages( - path=av.__path__, - prefix=av.__name__ + '.', - onerror=lambda x: None + path=av.__path__, prefix=av.__name__ + ".", onerror=lambda x: None ): register_doctests(mod_name) diff --git a/tests/test_encode.py b/tests/test_encode.py index 2a58044d6..95942b4b7 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -1,29 +1,34 @@ -from __future__ import division +from __future__ import annotations -from fractions import Fraction -from unittest import SkipTest +import io import math +import os +from fractions import Fraction + +import numpy as np +import pytest +import av +import av.codec.hwaccel from av import AudioFrame, VideoFrame from av.audio.stream import AudioStream from av.video.stream import VideoStream -import av - -from .common import Image, TestCase, fate_suite +from .common import TestCase, fate_suite, has_pillow WIDTH = 320 HEIGHT = 240 DURATION = 48 -def write_rgb_rotate(output): +def write_rgb_rotate(output: av.container.OutputContainer) -> None: + if not has_pillow: + pytest.skip() - if not Image: - raise SkipTest() + import PIL.Image as Image - output.metadata['title'] = 'container' - output.metadata['key'] = 'value' + output.metadata["title"] = "container" + output.metadata["key"] = "value" stream = output.add_stream("mpeg4", 24) stream.width = WIDTH @@ -31,180 +36,629 @@ def write_rgb_rotate(output): stream.pix_fmt = "yuv420p" for frame_i in range(DURATION): - - frame = VideoFrame(WIDTH, HEIGHT, 'rgb24') - image = Image.new('RGB', (WIDTH, HEIGHT), ( - int(255 * (0.5 + 0.5 * math.sin(frame_i / DURATION * 2 * math.pi))), - int(255 * (0.5 + 0.5 * math.sin(frame_i / DURATION * 2 * math.pi + 2 / 3 * math.pi))), - int(255 * (0.5 + 0.5 * math.sin(frame_i / DURATION * 2 * math.pi + 4 / 3 * math.pi))), - )) + frame = VideoFrame(WIDTH, HEIGHT, "rgb24") + image = Image.new( + "RGB", + (WIDTH, HEIGHT), + ( + int(255 * (0.5 + 0.5 * math.sin(frame_i / DURATION * 2 * math.pi))), + int( + 255 + * ( + 0.5 + + 0.5 + * math.sin(frame_i / DURATION * 2 * math.pi + 2 / 3 * math.pi) + ) + ), + int( + 255 + * ( + 0.5 + + 0.5 + * math.sin(frame_i / DURATION * 2 * math.pi + 4 / 3 * math.pi) + ) + ), + ), + ) frame.planes[0].update(image.tobytes()) - for packet in stream.encode(frame): + for packet in stream.encode_lazy(frame): output.mux(packet) - for packet in stream.encode(None): + for packet in stream.encode_lazy(None): output.mux(packet) - # Done! - output.close() - - -def assert_rgb_rotate(self, input_): +def assert_rgb_rotate( + self, input_: av.container.InputContainer, is_dash: bool = False +) -> None: # Now inspect it a little. - self.assertEqual(len(input_.streams), 1) - self.assertEqual(input_.metadata.get('title'), 'container', input_.metadata) - self.assertEqual(input_.metadata.get('key'), None) + assert len(input_.streams) == 1 + assert input_.metadata.get("Title" if is_dash else "title") == "container" + assert input_.metadata.get("key") is None + stream = input_.streams[0] - self.assertIsInstance(stream, VideoStream) - self.assertEqual(stream.type, 'video') - self.assertEqual(stream.name, 'mpeg4') - self.assertEqual(stream.average_rate, 24) # Only because we constructed is precisely. - self.assertEqual(stream.rate, Fraction(24, 1)) - self.assertEqual(stream.time_base * stream.duration, 2) - self.assertEqual(stream.format.name, 'yuv420p') - self.assertEqual(stream.format.width, WIDTH) - self.assertEqual(stream.format.height, HEIGHT) + + if is_dash: + # The DASH format doesn't provide a duration for the stream + # and so the container duration (micro seconds) is checked instead + assert input_.duration == 2000000 + expected_average_rate = 24 + expected_duration = None + expected_frames = 0 + expected_id = 0 + else: + expected_average_rate = 24 + expected_duration = 24576 + expected_frames = 48 + expected_id = 1 + + # actual stream properties + assert isinstance(stream, VideoStream) + assert stream.average_rate == expected_average_rate + assert stream.base_rate == 24 + assert stream.duration == expected_duration + assert stream.guessed_rate == 24 + assert stream.frames == expected_frames + assert stream.id == expected_id + assert stream.index == 0 + assert stream.profile == "Simple Profile" + assert stream.start_time == 0 + assert stream.time_base == Fraction(1, 12288) + assert stream.type == "video" + + # codec context properties + assert stream.codec.name == "mpeg4" + assert stream.codec.long_name == "MPEG-4 part 2" + assert stream.format.name == "yuv420p" + assert stream.format.width == WIDTH + assert stream.format.height == HEIGHT class TestBasicVideoEncoding(TestCase): + def test_default_options(self) -> None: + with av.open(self.sandboxed("output.mov"), "w") as output: + stream = output.add_stream("mpeg4") + assert stream in output.streams.video + assert stream.average_rate == Fraction(24, 1) + assert stream.time_base is None + + # codec context properties + assert stream.format.height == 480 + assert stream.format.name == "yuv420p" + assert stream.format.width == 640 + assert stream.height == 480 + assert stream.pix_fmt == "yuv420p" + assert stream.width == 640 + + def test_encoding(self) -> None: + path = self.sandboxed("rgb_rotate.mov") + + with av.open(path, "w") as output: + write_rgb_rotate(output) + with av.open(path) as input: + assert_rgb_rotate(self, input) + + def test_encoding_with_pts(self) -> None: + path = self.sandboxed("video_with_pts.mov") + + with av.open(path, "w") as output: + stream = output.add_stream("h264", 24) + assert stream in output.streams.video + stream.width = WIDTH + stream.height = HEIGHT + stream.pix_fmt = "yuv420p" + + for i in range(DURATION): + frame = VideoFrame(WIDTH, HEIGHT, "rgb24") + frame.pts = i * 2000 + frame.time_base = Fraction(1, 48000) + + for packet in stream.encode(frame): + assert packet.time_base == Fraction(1, 24) + output.mux(packet) + + for packet in stream.encode(None): + assert packet.time_base == Fraction(1, 24) + output.mux(packet) + + def test_set_rate_after_add_stream(self) -> None: + path = self.sandboxed("deferred_rate.mp4") - def test_rgb_rotate(self): + with av.open(path, "w") as output: + stream = output.add_stream("mpeg4") + stream.codec_context.framerate = Fraction(30, 1) + stream.width = 16 + stream.height = 16 - path = self.sandboxed('rgb_rotate.mov') - output = av.open(path, 'w') + for i in range(30): + frame = VideoFrame(16, 16, "yuv420p") + frame.pts = i + frame.time_base = Fraction(1, 30) + output.mux(stream.encode(frame)) - write_rgb_rotate(output) - assert_rgb_rotate(self, av.open(path)) + output.mux(stream.encode(None)) - def test_encoding_with_pts(self): + with av.open(path) as input_: + assert input_.streams.video[0].average_rate == 30 - path = self.sandboxed('video_with_pts.mov') - output = av.open(path, 'w') + def test_encoding_with_unicode_filename(self) -> None: + path = self.sandboxed("¢∞§¶•ªº.mov") - stream = output.add_stream('libx264', 24) - stream.width = WIDTH - stream.height = HEIGHT - stream.pix_fmt = "yuv420p" + with av.open(path, "w") as output: + write_rgb_rotate(output) + with av.open(path) as input: + assert_rgb_rotate(self, input) - for i in range(DURATION): - frame = VideoFrame(WIDTH, HEIGHT, 'rgb24') - frame.pts = i * 2000 - frame.time_base = Fraction(1, 48000) - for packet in stream.encode(frame): - self.assertEqual(packet.time_base, Fraction(1, 24)) +class TestBasicAudioEncoding(TestCase): + def test_default_options(self) -> None: + with av.open(self.sandboxed("output.mov"), "w") as output: + stream = output.add_stream("mp2") + assert stream in output.streams.audio + assert stream.time_base is None + + # codec context properties + assert stream.format.name == "s16" + assert stream.sample_rate == 48000 + + def test_transcode(self) -> None: + path = self.sandboxed("audio_transcode.mov") + + with av.open(path, "w") as output: + output.metadata["title"] = "container" + output.metadata["key"] = "value" + + sample_rate = 48000 + channel_layout = "stereo" + sample_fmt = "s16" + + stream = output.add_stream("mp2", sample_rate) + assert stream in output.streams.audio + + ctx = stream.codec_context + ctx.sample_rate = sample_rate + stream.format = sample_fmt + ctx.layout = channel_layout + + with av.open( + fate_suite("audio-reference/chorusnoise_2ch_44kHz_s16.wav") + ) as src: + for frame in src.decode(audio=0): + for packet in stream.encode(frame): + output.mux(packet) + + for packet in stream.encode(None): output.mux(packet) - for packet in stream.encode(None): - self.assertEqual(packet.time_base, Fraction(1, 24)) + with av.open(path) as container: + assert len(container.streams) == 1 + assert container.metadata.get("title") == "container" + assert container.metadata.get("key") is None + + assert isinstance(container.streams[0], AudioStream) + stream = container.streams[0] + + # codec context properties + assert stream.format.name == "s16p" + assert stream.sample_rate == sample_rate + + +class TestSubtitleEncoding: + def test_subtitle_muxing(self) -> None: + input_ = av.open(fate_suite("sub/MovText_capability_tester.mp4")) + in_stream = input_.streams.subtitles[0] + + output_bytes = io.BytesIO() + output = av.open(output_bytes, "w", format="mp4") + + out_stream = output.add_stream_from_template(in_stream) + + for packet in input_.demux(in_stream): + if packet.size == 0: + continue + packet.stream = out_stream output.mux(packet) output.close() + output_bytes.seek(0) + assert output_bytes.getvalue().startswith( + b"\x00\x00\x00\x1cftypisom\x00\x00\x02\x00isomiso2mp41\x00\x00\x00\x08free" + ) -class TestBasicAudioEncoding(TestCase): +class TestEncodeStreamSemantics(TestCase): + def test_reconfigure_stream_after_mux(self) -> None: + output_bytes = io.BytesIO() + with av.open(output_bytes, "w", format="mp4") as output: + first = output.add_stream("ffv1", rate=30) + second = output.add_stream("ffv1", rate=30) + + first.format = av.VideoFormat("bgr0", width=16, height=16) + frame = VideoFrame(16, 16, "bgr0") + frame.pts = 0 + frame.time_base = Fraction(1, 30) + output.mux(first.encode(frame)) + + # Muxing the first packet writes the header and opens every stream. + # Changing the second encoder now used to corrupt FFV1 state and crash. + assert second.codec_context.is_open + with pytest.raises(RuntimeError, match="Cannot change format"): + second.format = av.VideoFormat("bgr0", width=16, height=16) + with pytest.raises(RuntimeError, match="Cannot change width"): + second.width = 16 + with pytest.raises(RuntimeError, match="Cannot change height"): + second.height = 16 + with pytest.raises(RuntimeError, match="Cannot change pix_fmt"): + second.pix_fmt = "bgr0" + + def test_stream_index(self) -> None: + with av.open(self.sandboxed("output.mov"), "w") as output: + vstream = output.add_stream("mpeg4", 24) + assert vstream in output.streams.video + vstream.pix_fmt = "yuv420p" + vstream.width = 320 + vstream.height = 240 + + astream = output.add_stream("mp2", 48000) + assert astream in output.streams.audio + astream.layout = "stereo" + astream.format = "s16" + + assert vstream.index == 0 + assert astream.index == 1 + + vframe = VideoFrame(320, 240, "yuv420p") + vpacket = vstream.encode(vframe)[0] + + assert vpacket.stream is vstream + assert vpacket.stream_index == 0 + + for i in range(10): + if astream.frame_size != 0: + frame_size = astream.frame_size + else: + # decoder didn't indicate constant frame size + frame_size = 1000 + aframe = AudioFrame("s16", "stereo", samples=frame_size) + aframe.sample_rate = 48000 + apackets = astream.encode(aframe) + if apackets: + apacket = apackets[0] + break + + assert apacket.stream is astream + assert apacket.stream_index == 1 + + def test_stream_audio_resample(self) -> None: + with av.open(self.sandboxed("output.mov"), "w") as output: + vstream = output.add_stream("mpeg4", 24) + vstream.pix_fmt = "yuv420p" + vstream.width = 320 + vstream.height = 240 + + astream = output.add_stream("aac", sample_rate=8000, layout="mono") + frame_size = 512 + + pts_expected = [-1024, 0, 512, 1024, 1536, 2048, 2560] + pts = 0 + for i in range(15): + aframe = AudioFrame("s16", "mono", samples=frame_size) + aframe.sample_rate = 8000 + aframe.time_base = Fraction(1, 1000) + aframe.pts = pts + aframe.dts = pts + pts += 32 + apackets = astream.encode(aframe) + if apackets: + apacket = apackets[0] + assert apacket.pts == pts_expected.pop(0) + assert apacket.time_base == Fraction(1, 8000) + + apackets = astream.encode(None) + if apackets: + apacket = apackets[0] + assert apacket.pts == pts_expected.pop(0) + assert apacket.time_base == Fraction(1, 8000) + + def test_set_id_and_time_base(self) -> None: + with av.open(self.sandboxed("output.mov"), "w") as output: + stream = output.add_stream("mp2") + assert stream in output.streams.audio + + # set id + assert stream.id == 0 + stream.id = 1 + assert stream.id == 1 + + # set time_base + assert stream.time_base is None + stream.time_base = Fraction(1, 48000) + assert stream.time_base == Fraction(1, 48000) + + +def encode_file_with_max_b_frames(max_b_frames: int) -> io.BytesIO: + """ + Create an encoded video file (or file-like object) with the given + maximum run of B frames. + + max_b_frames: non-negative integer which is the maximum allowed run + of consecutive B frames. + + Returns: a file-like object. + """ + # Create a video file that is entirely arbitrary, but with the passed + # max_b_frames parameter. + file = io.BytesIO() + container = av.open(file, mode="w", format="mp4") + stream = container.add_stream("h264", rate=30) + stream.width = 640 + stream.height = 480 + stream.pix_fmt = "yuv420p" + stream.codec_context.gop_size = 15 + stream.codec_context.max_b_frames = max_b_frames + + for i in range(50): + array = np.empty((stream.height, stream.width, 3), dtype=np.uint8) + # This appears to hit a complexity "sweet spot" that makes the codec + # want to use B frames. + array[:, :] = (i, 0, 255 - i) + frame = av.VideoFrame.from_ndarray(array, format="rgb24") + for packet in stream.encode(frame): + container.mux(packet) - def test_audio_transcode(self): + for packet in stream.encode(): + container.mux(packet) - path = self.sandboxed('audio_transcode.mov') - output = av.open(path, 'w') - output.metadata['title'] = 'container' - output.metadata['key'] = 'value' + container.close() + file.seek(0) - sample_rate = 48000 - channel_layout = 'stereo' - channels = 2 - sample_fmt = 's16' + return file - stream = output.add_stream('mp2', sample_rate) - ctx = stream.codec_context - ctx.time_base = sample_rate - ctx.sample_rate = sample_rate - ctx.format = sample_fmt - ctx.layout = channel_layout - ctx.channels = channels +def max_b_frame_run_in_file(file: io.BytesIO) -> int: + """ + Count the maximum run of B frames in a file (or file-like object). - src = av.open(fate_suite('audio-reference/chorusnoise_2ch_44kHz_s16.wav')) - for frame in src.decode(audio=0): - frame.pts = None - for packet in stream.encode(frame): - output.mux(packet) + file: the file or file-like object in which to count the maximum run + of B frames. The file should contain just one video stream. - for packet in stream.encode(None): - output.mux(packet) + Returns: non-negative integer which is the maximum B frame run length. + """ + container = av.open(file, "r") + stream = container.streams.video[0] - output.close() + max_b_frame_run = 0 + b_frame_run = 0 + for frame in container.decode(stream): + if frame.pict_type == av.video.frame.PictureType.B: + b_frame_run += 1 + else: + max_b_frame_run = max(max_b_frame_run, b_frame_run) + b_frame_run = 0 - container = av.open(path) - self.assertEqual(len(container.streams), 1) - self.assertEqual(container.metadata.get('title'), 'container', container.metadata) - self.assertEqual(container.metadata.get('key'), None) + # Outside chance that the longest run was at the end of the file. + max_b_frame_run = max(max_b_frame_run, b_frame_run) - stream = container.streams[0] - self.assertIsInstance(stream, AudioStream) - self.assertEqual(stream.codec_context.sample_rate, sample_rate) - self.assertEqual(stream.codec_context.format.name, 's16p') - self.assertEqual(stream.codec_context.channels, channels) + container.close() + return max_b_frame_run -class TestEncodeStreamSemantics(TestCase): - def test_audio_default_options(self): - output = av.open(self.sandboxed('output.mov'), 'w') - - stream = output.add_stream('mp2') - self.assertEqual(stream.bit_rate, 128000) - self.assertEqual(stream.format.name, 's16') - self.assertEqual(stream.rate, 48000) - self.assertEqual(stream.ticks_per_frame, 1) - self.assertEqual(stream.time_base, None) - - def test_video_default_options(self): - output = av.open(self.sandboxed('output.mov'), 'w') - - stream = output.add_stream('mpeg4') - self.assertEqual(stream.bit_rate, 1024000) - self.assertEqual(stream.format.height, 480) - self.assertEqual(stream.format.name, 'yuv420p') - self.assertEqual(stream.format.width, 640) - self.assertEqual(stream.height, 480) - self.assertEqual(stream.pix_fmt, 'yuv420p') - self.assertEqual(stream.rate, Fraction(24, 1)) - self.assertEqual(stream.ticks_per_frame, 1) - self.assertEqual(stream.time_base, None) - self.assertEqual(stream.width, 640) - - def test_stream_index(self): - output = av.open(self.sandboxed('output.mov'), 'w') - - vstream = output.add_stream('mpeg4', 24) - vstream.pix_fmt = 'yuv420p' - vstream.width = 320 - vstream.height = 240 - - astream = output.add_stream('mp2', 48000) - astream.channels = 2 - astream.format = 's16' - - self.assertEqual(vstream.index, 0) - self.assertEqual(astream.index, 1) - - vframe = VideoFrame(320, 240, 'yuv420p') - vpacket = vstream.encode(vframe)[0] - - self.assertIs(vpacket.stream, vstream) - self.assertEqual(vpacket.stream_index, 0) - - for i in range(10): - aframe = AudioFrame('s16', 'stereo', samples=astream.frame_size) - aframe.rate = 48000 - apackets = astream.encode(aframe) - if apackets: - apacket = apackets[0] - break +class TestMaxBFrameEncoding(TestCase): + def test_max_b_frames(self) -> None: + """ + Test that we never get longer runs of B frames than we asked for with + the max_b_frames property. + """ + for max_b_frames in range(4): + file = encode_file_with_max_b_frames(max_b_frames) + actual_max_b_frames = max_b_frame_run_in_file(file) + assert actual_max_b_frames <= max_b_frames + + +def encode_frames_with_qminmax( + frames: list[VideoFrame], shape: tuple[int, int, int], qminmax: tuple[int, int] +) -> int: + """ + Encode a video with the given quantiser limits, and return how many encoded + bytes we made in total. + + frames: the frames to encode + shape: the (numpy) shape of the video frames + qminmax: two integers with 1 <= qmin <= 31 giving the min and max quantiser. + + Returns: total length of the encoded bytes. + """ - self.assertIs(apacket.stream, astream) - self.assertEqual(apacket.stream_index, 1) + if av.codec.Codec("h264", "w").name != "libx264": + pytest.skip() + + file = io.BytesIO() + container = av.open(file, mode="w", format="mp4") + stream = container.add_stream("h264", rate=30) + stream.height, stream.width, _ = shape + stream.pix_fmt = "yuv420p" + stream.codec_context.gop_size = 15 + stream.codec_context.qmin, stream.codec_context.qmax = qminmax + + bytes_encoded = 0 + for frame in frames: + for packet in stream.encode(frame): + bytes_encoded += packet.size + + for packet in stream.encode(): + bytes_encoded += packet.size + + container.close() + + return bytes_encoded + + +class TestQminQmaxEncoding(TestCase): + def test_qmin_qmax(self) -> None: + """ + Test that we can set the min and max quantisers, and the encoder is reacting + correctly to them. + + Can't see a way to get hold of the quantisers in a decoded video, so instead + we'll encode the same frames with decreasing quantisers, and check that the + file size increases (by a noticeable factor) each time. + """ + # Make a random - but repeatable - 10 frame video sequence. + np.random.seed(0) + frames = [] + shape = (480, 640, 3) + for _ in range(10): + frames.append( + av.VideoFrame.from_ndarray( + np.random.randint(0, 256, shape, dtype=np.uint8), format="rgb24" + ) + ) + + # Get the size of the encoded output for different quantisers. + quantisers = ((31, 31), (15, 15), (1, 1)) + sizes = [ + encode_frames_with_qminmax(frames, shape, qminmax) for qminmax in quantisers + ] + + factor = 1.3 # insist at least 30% larger each time + assert all(small * factor < large for small, large in zip(sizes, sizes[1:])) + + +class TestProfiles(TestCase): + def test_profiles(self) -> None: + """ + Test that we can set different encoder profiles. + """ + # Let's try a video and an audio codec. + file = io.BytesIO() + codecs = ( + ("h264", 30), + ("aac", 48000), + ) + + for codec_name, rate in codecs: + print("Testing:", codec_name) + container = av.open(file, mode="w", format="mp4") + stream = container.add_stream(codec_name, rate=rate) + assert len(stream.profiles) >= 1 # check that we're testing something! + + # It should be enough to test setting and retrieving the code. That means + # libav has recognised the profile and set it correctly. + for profile in stream.profiles: + stream.profile = profile + print("Set", profile, "got", stream.profile) + assert stream.profile == profile + + +# Map a hardware device type to a video encoder that uses it. +_HWACCEL_ENCODERS = { + "vaapi": "h264_vaapi", + "cuda": "h264_nvenc", + "qsv": "h264_qsv", + "videotoolbox": "h264_videotoolbox", +} + + +def get_hwaccel_format(encoder: str, device_type: str) -> str: + for config in av.Codec(encoder, "w").hardware_configs: + if config.device_type.name == device_type and config.format is not None: + return config.format.name + pytest.skip(f"No hardware format for {device_type} on {encoder}") + + +def test_hardware_encode() -> None: + hwdevices_available = av.codec.hwaccel.hwdevices_available() + if "HWACCEL_DEVICE_TYPE" not in os.environ: + pytest.skip( + "Set the HWACCEL_DEVICE_TYPE to run this test. " + f"Options are {' '.join(hwdevices_available)}" + ) + + device_type = os.environ["HWACCEL_DEVICE_TYPE"] + assert device_type in hwdevices_available, f"{device_type} not available" + + encoder = _HWACCEL_ENCODERS.get(device_type) + if encoder is None: + pytest.skip(f"No hardware encoder mapped for {device_type}") + + width, height, n_frames = 320, 240, 24 + hwaccel = av.codec.hwaccel.HWAccel( + device_type=device_type, allow_software_fallback=False + ) + + file = io.BytesIO() + container = av.open(file, mode="w", format="mp4") + stream = container.add_stream(encoder, rate=30, hwaccel=hwaccel) + assert isinstance(stream, VideoStream) + stream.width = width + stream.height = height + stream.pix_fmt = "nv12" + + # Feed plain software frames; PyAV uploads them to the device for us. + muxed = 0 + for i in range(n_frames): + array = np.full((height, width, 3), i * 8 % 256, dtype=np.uint8) + frame = VideoFrame.from_ndarray(array, format="rgb24") + for packet in stream.encode(frame): + container.mux(packet) + muxed += 1 + + # The hardware frames context must have been set up during open(). + assert stream.codec_context.is_hwaccel + + for packet in stream.encode(): + container.mux(packet) + muxed += 1 + container.close() + + assert muxed > 0 + + # The result must be a valid, decodable H.264 stream. + file.seek(0) + with av.open(file, "r") as in_container: + decoded = sum(1 for _ in in_container.decode(video=0)) + assert decoded == n_frames + + +def test_hardware_encode_honors_sw_format() -> None: + hwdevices_available = av.codec.hwaccel.hwdevices_available() + if "HWACCEL_DEVICE_TYPE" not in os.environ: + pytest.skip( + "Set the HWACCEL_DEVICE_TYPE to run this test. " + f"Options are {' '.join(hwdevices_available)}" + ) + + device_type = os.environ["HWACCEL_DEVICE_TYPE"] + assert device_type in hwdevices_available, f"{device_type} not available" + + encoder = _HWACCEL_ENCODERS.get(device_type) + if encoder is None: + pytest.skip(f"No hardware encoder mapped for {device_type}") + hw_format = get_hwaccel_format(encoder, device_type) + + hwaccel = av.codec.hwaccel.HWAccel( + device_type=device_type, allow_software_fallback=False + ) + container = av.open(io.BytesIO(), mode="w", format="mp4") + stream = container.add_stream(encoder, rate=30, hwaccel=hwaccel) + assert isinstance(stream, VideoStream) + stream.width = 320 + stream.height = 240 + stream.pix_fmt = hw_format + stream.codec_context.sw_format = "yuv420p" + + assert stream.codec_context.sw_format is not None + assert stream.codec_context.sw_format.name == "yuv420p" + + frame = VideoFrame(320, 240, "rgb24") + for packet in stream.encode(frame): + container.mux(packet) + + assert stream.codec_context.pix_fmt == hw_format + assert stream.codec_context.sw_format is not None + assert stream.codec_context.sw_format.name == "yuv420p" + for packet in stream.encode(): + container.mux(packet) + container.close() diff --git a/tests/test_enums.py b/tests/test_enums.py deleted file mode 100644 index a45bb44a4..000000000 --- a/tests/test_enums.py +++ /dev/null @@ -1,236 +0,0 @@ -import pickle - -from av.enum import EnumType, define_enum - -from .common import TestCase - - -# This must be at the top-level. -PickleableFooBar = define_enum('PickleableFooBar', __name__, [('FOO', 1)]) - - -class TestEnums(TestCase): - - def define_foobar(self, **kwargs): - return define_enum('Foobar', __name__, ( - ('FOO', 1), - ('BAR', 2), - ), **kwargs) - - def test_basics(self): - - cls = self.define_foobar() - - self.assertIsInstance(cls, EnumType) - - foo = cls.FOO - - self.assertIsInstance(foo, cls) - self.assertEqual(foo.name, 'FOO') - self.assertEqual(foo.value, 1) - - self.assertNotIsInstance(foo, PickleableFooBar) - - def test_access(self): - - cls = self.define_foobar() - foo1 = cls.FOO - foo2 = cls['FOO'] - foo3 = cls[1] - foo4 = cls[foo1] - self.assertIs(foo1, foo2) - self.assertIs(foo1, foo3) - self.assertIs(foo1, foo4) - - self.assertIn(foo1, cls) - self.assertIn('FOO', cls) - self.assertIn(1, cls) - - self.assertRaises(KeyError, lambda: cls['not a foo']) - self.assertRaises(KeyError, lambda: cls[10]) - self.assertRaises(TypeError, lambda: cls[()]) - - self.assertEqual(cls.get('FOO'), foo1) - self.assertIs(cls.get('not a foo'), None) - - def test_casting(self): - - cls = self.define_foobar() - foo = cls.FOO - - self.assertEqual(repr(foo), '') - - str_foo = str(foo) - self.assertIsInstance(str_foo, str) - self.assertEqual(str_foo, 'FOO') - - int_foo = int(foo) - self.assertIsInstance(int_foo, int) - self.assertEqual(int_foo, 1) - - def test_iteration(self): - cls = self.define_foobar() - self.assertEqual(list(cls), [cls.FOO, cls.BAR]) - - def test_equality(self): - - cls = self.define_foobar() - foo = cls.FOO - bar = cls.BAR - - self.assertEqual(foo, 'FOO') - self.assertEqual(foo, 1) - self.assertEqual(foo, foo) - self.assertNotEqual(foo, 'BAR') - self.assertNotEqual(foo, 2) - self.assertNotEqual(foo, bar) - - self.assertRaises(ValueError, lambda: foo == 'not a foo') - self.assertRaises(ValueError, lambda: foo == 10) - self.assertRaises(TypeError, lambda: foo == ()) - - def test_as_key(self): - - cls = self.define_foobar() - foo = cls.FOO - - d = {foo: 'value'} - self.assertEqual(d[foo], 'value') - self.assertIs(d.get('FOO'), None) - self.assertIs(d.get(1), None) - - def test_pickleable(self): - - cls = PickleableFooBar - foo = cls.FOO - - enc = pickle.dumps(foo) - - foo2 = pickle.loads(enc) - - self.assertIs(foo, foo2) - - def test_create_unknown(self): - - cls = self.define_foobar() - baz = cls.get(3, create=True) - - self.assertEqual(baz.name, 'FOOBAR_3') - self.assertEqual(baz.value, 3) - - def test_multiple_names(self): - - cls = define_enum('FFooBBar', __name__, ( - ('FOO', 1), - ('F', 1), - ('BAR', 2), - ('B', 2), - )) - - self.assertIs(cls.F, cls.FOO) - - self.assertEqual(cls.F.name, 'FOO') - self.assertNotEqual(cls.F.name, 'F') # This is actually the string. - - self.assertEqual(cls.F, 'FOO') - self.assertEqual(cls.F, 'F') - self.assertNotEqual(cls.F, 'BAR') - self.assertNotEqual(cls.F, 'B') - self.assertRaises(ValueError, lambda: cls.F == 'x') - - def test_flag_basics(self): - - cls = define_enum('FoobarAllFlags', __name__, dict(FOO=1, BAR=2, FOOBAR=3).items(), is_flags=True) - foo = cls.FOO - bar = cls.BAR - - foobar = foo | bar - self.assertIs(foobar, cls.FOOBAR) - - foo2 = foobar & foo - self.assertIs(foo2, foo) - - bar2 = foobar ^ foo - self.assertIs(bar2, bar) - - bar3 = foobar & ~foo - self.assertIs(bar3, bar) - - x = cls.FOO - x |= cls.BAR - self.assertIs(x, cls.FOOBAR) - - x = cls.FOOBAR - x &= cls.FOO - self.assertIs(x, cls.FOO) - - def test_multi_flags_basics(self): - - cls = self.define_foobar(is_flags=True) - - foo = cls.FOO - bar = cls.BAR - foobar = foo | bar - self.assertEqual(foobar.name, 'FOO|BAR') - self.assertEqual(foobar.value, 3) - self.assertEqual(foobar.flags, (foo, bar)) - - foobar2 = foo | bar - foobar3 = cls[3] - foobar4 = cls[foobar] - - self.assertIs(foobar, foobar2) - self.assertIs(foobar, foobar3) - self.assertIs(foobar, foobar4) - - self.assertRaises(KeyError, lambda: cls['FOO|BAR']) - - self.assertEqual(len(cls), 2) # It didn't get bigger - self.assertEqual(list(cls), [foo, bar]) - - def test_multi_flags_create_missing(self): - - cls = self.define_foobar(is_flags=True) - - foobar = cls[3] - self.assertIs(foobar, cls.FOO | cls.BAR) - - self.assertRaises(KeyError, lambda: cls[4]) # Not FOO or BAR - self.assertRaises(KeyError, lambda: cls[7]) # FOO and BAR and missing flag. - - def test_properties(self): - - Flags = self.define_foobar(is_flags=True) - foobar = Flags.FOO | Flags.BAR - - class Class(object): - - def __init__(self, value): - self.value = Flags[value].value - - @Flags.property - def flags(self): - return self.value - - @flags.setter - def flags(self, value): - self.value = value - - foo = flags.flag_property('FOO') - bar = flags.flag_property('BAR') - - obj = Class('FOO') - - self.assertIs(obj.flags, Flags.FOO) - self.assertTrue(obj.foo) - self.assertFalse(obj.bar) - - obj.bar = True - self.assertIs(obj.flags, foobar) - self.assertTrue(obj.foo) - self.assertTrue(obj.bar) - - obj.foo = False - self.assertIs(obj.flags, Flags.BAR) - self.assertFalse(obj.foo) - self.assertTrue(obj.bar) diff --git a/tests/test_errors.py b/tests/test_errors.py index 924fdbed0..f4dbf8fbc 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,68 +1,78 @@ import errno -import traceback +from traceback import format_exception_only import av -from .common import TestCase, is_windows +from .common import is_windows -class TestErrorBasics(TestCase): +def test_stringify() -> None: + for cls in (av.FileNotFoundError, av.DecoderNotFoundError): + e = cls(1, "foo") + assert f"{e}" == "[Errno 1] foo" + assert f"{e!r}" == f"{cls.__name__}(1, 'foo')" + assert ( + format_exception_only(cls, e)[-1] + == f"av.error.{cls.__name__}: [Errno 1] foo\n" + ) - def test_stringify(self): + for cls in (av.FileNotFoundError, av.DecoderNotFoundError): + e = cls(1, "foo", "bar.txt") + assert f"{e}" == "[Errno 1] foo: 'bar.txt'" + assert f"{e!r}" == f"{cls.__name__}(1, 'foo', 'bar.txt')" + assert ( + format_exception_only(cls, e)[-1] + == f"av.error.{cls.__name__}: [Errno 1] foo: 'bar.txt'\n" + ) - for cls in (av.ValueError, av.FileNotFoundError, av.DecoderNotFoundError): - e = cls(1, 'foo') - self.assertEqual(str(e), '[Errno 1] foo') - self.assertEqual(repr(e), "{}(1, 'foo')".format(cls.__name__)) - self.assertEqual( - traceback.format_exception_only(cls, e)[-1], - '{}{}: [Errno 1] foo\n'.format( - 'av.error.', - cls.__name__, - ), - ) - for cls in (av.ValueError, av.FileNotFoundError, av.DecoderNotFoundError): - e = cls(1, 'foo', 'bar.txt') - self.assertEqual(str(e), "[Errno 1] foo: 'bar.txt'") - self.assertEqual(repr(e), "{}(1, 'foo', 'bar.txt')".format(cls.__name__)) - self.assertEqual( - traceback.format_exception_only(cls, e)[-1], - "{}{}: [Errno 1] foo: 'bar.txt'\n".format( - 'av.error.', - cls.__name__, - ), +def test_bases() -> None: + assert issubclass(av.ArgumentError, av.FFmpegError) + # Deprecated, assert false for Major 18.0: + assert issubclass(av.ArgumentError, ValueError) + assert issubclass(av.FileNotFoundError, FileNotFoundError) + assert issubclass(av.FileNotFoundError, OSError) + assert issubclass(av.FileNotFoundError, av.FFmpegError) + + +def test_filenotfound(): + """Catch using builtin class on Python 3.3""" + try: + av.open("does not exist") + except FileNotFoundError as e: + assert e.errno == errno.ENOENT + if is_windows: + assert e.strerror in ( + "Error number -2 occurred", + "No such file or directory", ) + else: + assert e.strerror == "No such file or directory" + assert e.filename == "does not exist" + else: + assert False, "No exception raised!" - def test_bases(self): - self.assertTrue(issubclass(av.ValueError, ValueError)) - self.assertTrue(issubclass(av.ValueError, av.FFmpegError)) +def test_buffertoosmall() -> None: + """Throw an exception from an enum.""" - self.assertTrue(issubclass(av.FileNotFoundError, FileNotFoundError)) - self.assertTrue(issubclass(av.FileNotFoundError, OSError)) - self.assertTrue(issubclass(av.FileNotFoundError, av.FFmpegError)) + BUFFER_TOO_SMALL = 1397118274 + try: + av.error.err_check(-BUFFER_TOO_SMALL) + except av.error.BufferTooSmallError as e: + assert e.errno == BUFFER_TOO_SMALL + else: + assert False, "No exception raised!" - def test_filenotfound(self): - """Catch using builtin class on Python 3.3""" - try: - av.open('does not exist') - except FileNotFoundError as e: - self.assertEqual(e.errno, errno.ENOENT) - if is_windows: - self.assertTrue(e.strerror in ['Error number -2 occurred', - 'No such file or directory']) - else: - self.assertEqual(e.strerror, 'No such file or directory') - self.assertEqual(e.filename, 'does not exist') - else: - self.fail('no exception raised') - def test_buffertoosmall(self): - """Throw an exception from an enum.""" - try: - av.error.err_check(-av.error.BUFFER_TOO_SMALL.value) - except av.BufferTooSmallError as e: - self.assertEqual(e.errno, av.error.BUFFER_TOO_SMALL.value) - else: - self.fail('no exception raised') +def test_http_too_many_requests() -> None: + """HTTP 429 maps to HTTPTooManyRequestsError, not UndefinedError.""" + + HTTP_TOO_MANY_REQUESTS = av.error.tag_to_code(b"\xf8429") + try: + av.error.err_check(-HTTP_TOO_MANY_REQUESTS) + except av.error.HTTPTooManyRequestsError as e: + assert e.errno == HTTP_TOO_MANY_REQUESTS + assert isinstance(e, av.error.HTTPClientError) + else: + assert False, "No exception raised!" diff --git a/tests/test_file_probing.py b/tests/test_file_probing.py index e40a1c55f..ce04189f9 100644 --- a/tests/test_file_probing.py +++ b/tests/test_file_probing.py @@ -1,5 +1,3 @@ -from __future__ import division - from fractions import Fraction import av @@ -7,226 +5,310 @@ from .common import TestCase, fate_suite -try: - long -except NameError: - long = int - - class TestAudioProbe(TestCase): def setUp(self): - self.file = av.open(fate_suite('aac/latm_stereo_to_51.ts')) - - def test_container_probing(self): - self.assertEqual(str(self.file.format), "") - self.assertEqual(self.file.format.name, 'mpegts') - self.assertEqual(self.file.format.long_name, "MPEG-TS (MPEG-2 Transport Stream)") - self.assertEqual(self.file.size, 207740) + self.file = av.open(fate_suite("aac/latm_stereo_to_51.ts")) + + def test_container_probing(self) -> None: + assert self.file.bit_rate == 269558 + assert self.file.duration == 6165333 + assert str(self.file.format) == "" + assert self.file.format.name == "mpegts" + assert self.file.format.long_name == "MPEG-TS (MPEG-2 Transport Stream)" + assert self.file.metadata == {} + assert self.file.size == 207740 + assert self.file.start_time == 1400000 + assert len(self.file.streams) == 1 + + def test_stream_probing(self) -> None: + stream = self.file.streams[0] - # This is a little odd, but on OS X with FFmpeg we get a different value. - self.assertIn(self.file.bit_rate, (269558, 270494)) + assert isinstance(stream, av.AudioStream) + assert str(stream).startswith( + " None: + # write an empty file + path = self.sandboxed("empty.flac") + with open(path, "wb"): + pass + + self.file = av.open(path, "r") + + def test_container_probing(self) -> None: + assert self.file.bit_rate == 0 + assert self.file.duration is None + assert str(self.file.format) == "" + assert self.file.format.name == "flac" + assert self.file.format.long_name == "raw FLAC" + assert self.file.metadata == {} + assert self.file.size == 0 + assert self.file.start_time is None + assert len(self.file.streams) == 1 + + def test_stream_probing(self) -> None: stream = self.file.streams[0] + assert isinstance(stream, av.AudioStream) + assert str(stream).startswith( + "") - self.assertEqual(self.file.format.name, 'mxf') - self.assertEqual(self.file.format.long_name, 'MXF (Material eXchange Format)') - self.assertEqual(self.file.size, 1453153) - - self.assertEqual(self.file.bit_rate, 8 * self.file.size * av.time_base // self.file.duration) - self.assertEqual(self.file.duration, 417083) - self.assertEqual(len(self.file.streams), 4) - - for key, value, min_version in ( - ('application_platform', 'AAFSDK (MacOS X)', None), - ('comment_Comments', 'example comment', None), - ('comment_UNC Path', '/Users/mark/Desktop/dnxhr_tracknames_export.aaf', None), - ('company_name', 'Avid Technology, Inc.', None), - ('generation_uid', 'b6bcfcab-70ff-7331-c592-233869de11d2', None), - ('material_package_name', 'Example.new.04', None), - ('material_package_umid', '0x060A2B340101010101010F001300000057E19D16BA8202DB060E2B347F7F2A80', None), - ('modification_date', '2016-09-20T20:33:26.000000Z', None), - # Next one is FFmpeg >= 4.2. - ('operational_pattern_ul', '060e2b34.04010102.0d010201.10030000', {'libavformat': (58, 29)}), - ('product_name', 'Avid Media Composer 8.6.3.43955', None), - ('product_uid', 'acfbf03a-4f42-a231-d0b7-c06ecd3d4ad7', None), - ('product_version', 'Unknown version', None), - ('project_name', 'UHD', None), - ('uid', '4482d537-4203-ea40-9e4e-08a22900dd39', None), + def setUp(self) -> None: + self.file = av.open(fate_suite("mxf/track_01_v02.mxf")) + + def test_container_probing(self) -> None: + assert self.file.bit_rate == 27872687 + assert self.file.duration == 417083 + assert str(self.file.format) == "" + assert self.file.format.name == "mxf" + assert self.file.format.long_name == "MXF (Material eXchange Format)" + assert self.file.size == 1453153 + assert self.file.start_time == 0 + assert len(self.file.streams) == 4 + + for key, value in ( + ("application_platform", "AAFSDK (MacOS X)"), + ("comment_Comments", "example comment"), + ("comment_UNC Path", "/Users/mark/Desktop/dnxhr_tracknames_export.aaf"), + ("company_name", "Avid Technology, Inc."), + ("generation_uid", "b6bcfcab-70ff-7331-c592-233869de11d2"), + ("material_package_name", "Example.new.04"), + ( + "material_package_umid", + "0x060A2B340101010101010F001300000057E19D16BA8202DB060E2B347F7F2A80", + ), + ("modification_date", "2016-09-20T20:33:26.000000Z"), + ("operational_pattern_ul", "060e2b34.04010102.0d010201.10030000"), + ("product_name", "Avid Media Composer 8.6.3.43955"), + ("product_uid", "acfbf03a-4f42-a231-d0b7-c06ecd3d4ad7"), + ("product_version", "Unknown version"), + ("project_name", "UHD"), + ("uid", "4482d537-4203-ea40-9e4e-08a22900dd39"), ): - if min_version and any( - av.library_versions[name] < version - for name, version in min_version.items() - ): - continue - self.assertEqual(self.file.metadata.get(key), value) - - def test_stream_probing(self): + assert self.file.metadata.get(key) == value + + def test_stream_probing(self) -> None: stream = self.file.streams[0] - # actual stream properties - self.assertEqual(stream.average_rate, None) - self.assertEqual(stream.base_rate, None) - self.assertEqual(stream.guessed_rate, None) - self.assertEqual(stream.duration, 37537) - self.assertEqual(stream.frames, 0) - self.assertEqual(stream.id, 1) - self.assertEqual(stream.index, 0) - self.assertEqual(stream.language, None) - self.assertEqual(stream.metadata, { - 'data_type': 'video', - 'file_package_umid': '0x060A2B340101010101010F001300000057E19D16BA8302DB060E2B347F7F2A80', - 'track_name': 'Base', - }) - self.assertEqual(stream.profile, None) - self.assertEqual(stream.start_time, 0) - self.assertEqual(stream.time_base, Fraction(1, 90000)) - self.assertEqual(stream.type, 'data') - - # codec properties - self.assertEqual(stream.name, None) - self.assertEqual(stream.long_name, None) + assert str(stream).startswith(" at ") + + assert stream.duration == 37537 + assert stream.frames == 0 + assert stream.id == 1 + assert stream.index == 0 + assert stream.language is None + assert stream.metadata == { + "data_type": "video", + "file_package_umid": "0x060A2B340101010101010F001300000057E19D16BA8302DB060E2B347F7F2A80", + "track_name": "Base", + } + assert stream.profile is None + assert stream.start_time == 0 + assert stream.time_base == Fraction(1, 90000) + assert stream.type == "data" + assert not hasattr(stream, "codec") class TestSubtitleProbe(TestCase): - def setUp(self): - self.file = av.open(fate_suite('sub/MovText_capability_tester.mp4')) - - def test_container_probing(self): - self.assertEqual(str(self.file.format), "") - self.assertEqual(self.file.format.name, 'mov,mp4,m4a,3gp,3g2,mj2') - self.assertEqual(self.file.format.long_name, 'QuickTime / MOV') - self.assertEqual(self.file.size, 825) - - self.assertEqual(self.file.bit_rate, 8 * self.file.size * av.time_base // self.file.duration) - self.assertEqual(self.file.duration, 8140000) - self.assertEqual(len(self.file.streams), 1) - self.assertEqual(self.file.metadata, { - 'compatible_brands': 'isom', - 'creation_time': '2012-07-04T05:10:41.000000Z', - 'major_brand': 'isom', - 'minor_version': '1', - }) - - def test_stream_probing(self): + def setUp(self) -> None: + self.file = av.open(fate_suite("sub/MovText_capability_tester.mp4")) + + def test_container_probing(self) -> None: + assert self.file.bit_rate == 810 + assert self.file.duration == 8140000 + assert str(self.file.format) == "" + assert self.file.format.name == "mov,mp4,m4a,3gp,3g2,mj2" + assert self.file.format.long_name == "QuickTime / MOV" + assert self.file.metadata == { + "compatible_brands": "isom", + "creation_time": "2012-07-04T05:10:41.000000Z", + "major_brand": "isom", + "minor_version": "1", + } + assert self.file.size == 825 + assert self.file.start_time is None + assert len(self.file.streams) == 1 + + def test_stream_probing(self) -> None: stream = self.file.streams[0] + assert str(stream).startswith("") - self.assertEqual(self.file.format.name, 'mpegts') - self.assertEqual(self.file.format.long_name, "MPEG-TS (MPEG-2 Transport Stream)") - self.assertEqual(self.file.size, 800000) +class TestVideoProbe(TestCase): + def setUp(self) -> None: + self.file = av.open(fate_suite("mpeg2/mpeg2_field_encoding.ts")) + + def test_container_probing(self) -> None: + assert self.file.bit_rate == 3950617 + assert self.file.duration == 1620000 + assert str(self.file.format) == "" + assert self.file.format.name == "mpegts" + assert self.file.format.long_name == "MPEG-TS (MPEG-2 Transport Stream)" + assert self.file.metadata == {} + assert self.file.size == 800000 + assert self.file.start_time == 22953408322 + assert len(self.file.streams) == 1 + + def test_stream_probing(self) -> None: + stream = self.file.streams[0] - # This is a little odd, but on OS X with FFmpeg we get a different value. - self.assertIn(self.file.duration, (1620000, 1580000)) + assert isinstance(stream, av.video.stream.VideoStream) + assert str(stream).startswith( + " None: + path = self.sandboxed("empty.h264") + with open(path, "wb"): + pass + + self.file = av.open(path) + + def test_container_probing(self) -> None: + assert str(self.file.format) == "" + assert self.file.format.name == "h264" + assert self.file.format.long_name == "raw H.264 video" + assert self.file.size == 0 + assert self.file.bit_rate == 0 + assert self.file.duration is None + + assert len(self.file.streams) == 1 + assert self.file.start_time is None + assert self.file.metadata == {} + + def test_stream_probing(self) -> None: stream = self.file.streams[0] + assert isinstance(stream, av.VideoStream) + assert str(stream).startswith(" AudioFrame: """ Generate audio frame representing part of the sinusoidal wave - :param input_format: default: s16 - :param layout: default: stereo - :param sample_rate: default: 44100 - :param frame_size: default: 1024 - :param frame_num: frame number - :return: audio frame for sinusoidal wave audio signal slice """ frame = AudioFrame(format=input_format, layout=layout, samples=frame_size) frame.sample_rate = sample_rate frame.pts = frame_num * frame_size - for i in range(len(frame.layout.channels)): + for i in range(frame.layout.nb_channels): data = np.zeros(frame_size, dtype=format_dtypes[input_format]) for j in range(frame_size): data[j] = np.sin(2 * np.pi * (frame_num + j) * (i + 1) / float(frame_size)) - frame.planes[i].update(data) + frame.planes[i].update(data) # type: ignore return frame -class TestFilters(TestCase): - - def test_filter_descriptor(self): - - f = Filter('testsrc') - self.assertEqual(f.name, 'testsrc') - self.assertEqual(f.description, 'Generate test pattern.') - self.assertFalse(f.dynamic_inputs) - self.assertEqual(len(f.inputs), 0) - self.assertFalse(f.dynamic_outputs) - self.assertEqual(len(f.outputs), 1) - self.assertEqual(f.outputs[0].name, 'default') - self.assertEqual(f.outputs[0].type, 'video') +def pull_until_blocked(graph: Graph) -> list[av.VideoFrame]: + frames: list[av.VideoFrame] = [] + while True: + try: + frames.append(graph.vpull()) + except av.FFmpegError as e: + if e.errno != errno.EAGAIN: + raise + return frames - def test_dynamic_filter_descriptor(self): - f = Filter('split') - self.assertFalse(f.dynamic_inputs) - self.assertEqual(len(f.inputs), 1) - self.assertTrue(f.dynamic_outputs) - self.assertEqual(len(f.outputs), 0) +class TestFilters(TestCase): + def test_filter_descriptor(self) -> None: + f = Filter("testsrc") + assert f.name == "testsrc" + assert f.description == "Generate test pattern." def test_generator_graph(self): - graph = Graph() - src = graph.add('testsrc') - lutrgb = graph.add('lutrgb', "r=maxval+minval-val:g=maxval+minval-val:b=maxval+minval-val", name='invert') - sink = graph.add('buffersink') + src = graph.add("testsrc") + lutrgb = graph.add( + "lutrgb", + "r=maxval+minval-val:g=maxval+minval-val:b=maxval+minval-val", + name="invert", + ) + sink = graph.add("buffersink") src.link_to(lutrgb) lutrgb.link_to(sink) # pads and links - self.assertIs(src.outputs[0].link.output, lutrgb.inputs[0]) - self.assertIs(lutrgb.inputs[0].link.input, src.outputs[0]) + assert src.outputs[0].link.output is lutrgb.inputs[0] + assert lutrgb.inputs[0].link.input is src.outputs[0] frame = sink.pull() - self.assertIsInstance(frame, VideoFrame) - - if Image: - frame.to_image().save(self.sandboxed('mandelbrot2.png')) + assert isinstance(frame, VideoFrame) - def test_auto_find_sink(self): + if has_pillow: + frame.to_image().save(self.sandboxed("mandelbrot2.png")) + def test_auto_find_sink(self) -> None: graph = Graph() - src = graph.add('testsrc') - src.link_to(graph.add('buffersink')) + src = graph.add("testsrc") + src.link_to(graph.add("buffersink")) graph.configure() - frame = graph.pull() + frame = graph.vpull() - if Image: - frame.to_image().save(self.sandboxed('mandelbrot3.png')) - - def test_delegate_sink(self): + if has_pillow: + frame.to_image().save(self.sandboxed("mandelbrot3.png")) + def test_delegate_sink(self) -> None: graph = Graph() - src = graph.add('testsrc') - src.link_to(graph.add('buffersink')) + src = graph.add("testsrc") + src.link_to(graph.add("buffersink")) graph.configure() frame = src.pull() + assert isinstance(frame, av.VideoFrame) - if Image: - frame.to_image().save(self.sandboxed('mandelbrot4.png')) - - def test_haldclut_graph(self): - - raise SkipTest() - - graph = Graph() - - img = Image.open(fate_suite('png1/lena-rgb24.png')) - frame = VideoFrame.from_image(img) - img_source = graph.add_buffer(frame) - - hald_img = Image.open('hald_7.png') - hald_frame = VideoFrame.from_image(hald_img) - hald_source = graph.add_buffer(hald_frame) - - hald_filter = graph.add('haldclut') - - sink = graph.add('buffersink') - - img_source.link(0, hald_filter, 0) - hald_source.link(0, hald_filter, 1) - hald_filter.link(0, sink, 0) - graph.config() - - self.assertIs(img_source.outputs[0].linked_to, hald_filter.inputs[0]) - self.assertIs(hald_source.outputs[0].linked_to, hald_filter.inputs[1]) - self.assertIs(hald_filter.outputs[0].linked_to, sink.inputs[0]) - - hald_source.push(hald_frame) - - img_source.push(frame) - - frame = sink.pull() - self.assertIsInstance(frame, VideoFrame) - frame.to_image().save(self.sandboxed('filtered.png')) + if has_pillow: + frame.to_image().save(self.sandboxed("mandelbrot4.png")) def test_audio_buffer_sink(self): graph = Graph() audio_buffer = graph.add_abuffer( - format='fltp', + format="fltp", sample_rate=48000, - layout='stereo', - time_base=Fraction(1, 48000) + layout="stereo", + time_base=Fraction(1, 48000), ) - audio_buffer.link_to(graph.add('abuffersink')) + audio_buffer.link_to(graph.add("abuffersink")) graph.configure() try: @@ -152,64 +114,232 @@ def test_audio_buffer_sink(self): if e.errno != errno.EAGAIN: raise - @staticmethod - def link_nodes(*nodes): - for c, n in zip(nodes, nodes[1:]): - c.link_to(n) - - def test_audio_buffer_resample(self): + def test_audio_buffer_resample(self) -> None: graph = Graph() - self.link_nodes( + graph.link_nodes( graph.add_abuffer( - format='fltp', + format="fltp", sample_rate=48000, - layout='stereo', - time_base=Fraction(1, 48000) + layout="stereo", + time_base=Fraction(1, 48000), ), graph.add( - 'aformat', - 'sample_fmts=s16:sample_rates=44100:channel_layouts=stereo' + "aformat", "sample_fmts=s16:sample_rates=44100:channel_layouts=stereo" ), - graph.add('abuffersink') + graph.add("abuffersink"), + ).configure() + + graph.push( + generate_audio_frame( + 0, input_format="fltp", layout="stereo", sample_rate=48000 + ) ) - graph.configure() + out_frame = graph.pull() + assert isinstance(out_frame, av.AudioFrame) + assert out_frame.format.name == "s16" + assert out_frame.layout.name == "stereo" + assert out_frame.sample_rate == 44100 + def test_audio_buffer_frame_size(self): + graph = Graph() + graph.link_nodes( + graph.add_abuffer( + format="fltp", + sample_rate=48000, + layout="stereo", + time_base=Fraction(1, 48000), + ), + graph.add("abuffersink"), + ).configure() + graph.set_audio_frame_size(256) graph.push( generate_audio_frame( 0, - input_format='fltp', - layout='stereo', - sample_rate=48000 + input_format="fltp", + layout="stereo", + sample_rate=48000, + frame_size=1024, ) ) out_frame = graph.pull() - self.assertEqual(out_frame.format.name, 's16') - self.assertEqual(out_frame.layout.name, 'stereo') - self.assertEqual(out_frame.sample_rate, 44100) + assert out_frame.sample_rate == 48000 + assert out_frame.samples == 256 def test_audio_buffer_volume_filter(self): graph = Graph() - self.link_nodes( + graph.link_nodes( graph.add_abuffer( - format='fltp', + format="fltp", sample_rate=48000, - layout='stereo', - time_base=Fraction(1, 48000) + layout="stereo", + time_base=Fraction(1, 48000), ), - graph.add('volume', volume='0.5'), - graph.add('abuffersink') - ) - graph.configure() + graph.add("volume", volume="0.5"), + graph.add("abuffersink"), + ).configure() - input_frame = generate_audio_frame(0, input_format='fltp', layout='stereo', sample_rate=48000) + input_frame = generate_audio_frame( + 0, input_format="fltp", layout="stereo", sample_rate=48000 + ) graph.push(input_frame) out_frame = graph.pull() - self.assertEqual(out_frame.format.name, 'fltp') - self.assertEqual(out_frame.layout.name, 'stereo') - self.assertEqual(out_frame.sample_rate, 48000) + assert out_frame.format.name == "fltp" + assert out_frame.layout.name == "stereo" + assert out_frame.sample_rate == 48000 input_data = input_frame.to_ndarray() output_data = out_frame.to_ndarray() - self.assertTrue(np.allclose(input_data * 0.5, output_data), "Check that volume is reduced") + assert np.allclose(input_data * 0.5, output_data) + + def test_audio_frame_metadata(self) -> None: + graph = Graph() + graph.link_nodes( + graph.add_abuffer( + format="fltp", + sample_rate=48000, + layout="stereo", + time_base=Fraction(1, 48000), + ), + graph.add("ebur128", "metadata=1"), + graph.add("abuffersink"), + ).configure() + + frame = AudioFrame.from_ndarray( + np.zeros((2, 4800), dtype=np.float32), + format="fltp", + layout="stereo", + ) + frame.sample_rate = 48000 + frame.time_base = Fraction(1, 48000) + frame.pts = 0 + + graph.push(frame) + graph.push(None) + output = graph.pull() + metadata = output.metadata + + assert "lavfi.r128.I" in metadata + assert "lavfi.r128.LRA" in metadata + + # Frame.metadata returns a copy of the underlying AVDictionary. + metadata.clear() + assert "lavfi.r128.I" in output.metadata + + def _test_video_buffer(self, graph): + input_container = av.open(format="lavfi", file="color=c=pink:duration=1:r=30") + input_video_stream = input_container.streams.video[0] + + buffer = graph.add_buffer(template=input_video_stream) + bwdif = graph.add("bwdif", "send_field:tff:all") + buffersink = graph.add("buffersink") + buffer.link_to(bwdif) + bwdif.link_to(buffersink) + graph.configure() + + for frame in input_container.decode(): + assert frame.time_base == Fraction(1, 30) + graph.vpush(frame) + filtered_frames = pull_until_blocked(graph) + + if frame.pts == 0: + # no output for the first input frame + assert len(filtered_frames) == 0 + else: + # we expect two filtered frames per input frame + assert len(filtered_frames) == 2 + + assert filtered_frames[0].pts == (frame.pts - 1) * 2 + assert filtered_frames[0].time_base == Fraction(1, 60) + + assert filtered_frames[1].pts == (frame.pts - 1) * 2 + 1 + assert filtered_frames[1].time_base == Fraction(1, 60) + + def test_video_buffer(self): + self._test_video_buffer(av.filter.Graph()) + + def test_video_buffer_threading(self): + graph = av.filter.Graph() + graph.threads = 4 + self._test_video_buffer(graph) + + def test_EOF(self) -> None: + input_container = av.open(format="lavfi", file="color=c=pink:duration=1:r=30") + video_stream = input_container.streams.video[0] + + graph = av.filter.Graph() + video_in = graph.add_buffer(template=video_stream) + palette_gen_filter = graph.add("palettegen") + video_out = graph.add("buffersink") + video_in.link_to(palette_gen_filter) + palette_gen_filter.link_to(video_out) + graph.configure() + + for frame in input_container.decode(video=0): + graph.vpush(frame) + + graph.vpush(None) + + # if we do not push None, we get a BlockingIOError + palette_frame = graph.vpull() + + assert isinstance(palette_frame, av.VideoFrame) + assert palette_frame.width == 16 + assert palette_frame.height == 16 + + def test_push_at_index(self) -> None: + # overlay has two video buffer sources; `at` targets a single one, + # instead of broadcasting the same frame to both (like auto-editor's + # pushIdx/flushIdx). + width, height = 16, 16 + + base = VideoFrame(width, height, "yuv420p") + for plane in base.planes: + plane.update(bytes(plane.buffer_size)) + base.pts = 0 + base.time_base = Fraction(1, 30) + + top = VideoFrame(width, height, "yuv420p") + for i, plane in enumerate(top.planes): + plane.update(bytes([200 if i == 0 else 128]) * plane.buffer_size) + top.pts = 0 + top.time_base = Fraction(1, 30) + + graph = Graph() + b0 = graph.add_buffer( + width=width, height=height, format=base.format, time_base=base.time_base + ) + b1 = graph.add_buffer( + width=width, height=height, format=top.format, time_base=top.time_base + ) + overlay = graph.add("overlay", "x=0:y=0") + sink = graph.add("buffersink") + b0.link_to(overlay, 0, 0) + b1.link_to(overlay, 0, 1) + overlay.link_to(sink) + graph.configure() + + graph.push(base, at=0) + graph.push(top, at=1) + graph.push(None, at=0) + graph.push(None, at=1) + + out = graph.vpull() + assert isinstance(out, av.VideoFrame) + assert (out.width, out.height) == (width, height) + + with self.assertRaises(IndexError): + graph.push(base, at=2) + + def test_graph_threads(self) -> None: + graph = Graph() + assert graph.threads == 0 + + graph.threads = 4 + assert graph.threads == 4 + + graph.add("testsrc") + + with self.assertRaises(RuntimeError): + graph.threads = 2 diff --git a/tests/test_indexentries.py b/tests/test_indexentries.py new file mode 100644 index 000000000..c0d5cd393 --- /dev/null +++ b/tests/test_indexentries.py @@ -0,0 +1,79 @@ +import av + +from .common import TestCase, fate_suite + + +class TestIndexEntries(TestCase): + def test_index_entries_len_mp4(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + assert len(stream.index_entries) == stream.frames + + def test_index_entries_len_webm(self) -> None: + with av.open( + fate_suite("vp9-test-vectors/vp90-2-00-quantizer-00.webm") + ) as container: + stream = container.streams.video[0] + index_entries_len_before_demux = len(stream.index_entries) + + keyframes = len([p for p in container.demux(video=0) if p.is_keyframe]) + assert index_entries_len_before_demux == keyframes + + def test_index_entries_search_timestamp_options_mp4(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + fi = stream.index_entries + + target_ts = -1 + + assert fi.search_timestamp(target_ts) == 0 + assert fi.search_timestamp(target_ts, any_frame=True) == 1 + assert fi.search_timestamp(target_ts, backward=False) == 21 + assert fi.search_timestamp(target_ts, backward=False, any_frame=True) == 1 + + e0 = fi[0] + e1 = fi[1] + e21 = fi[21] + assert e0.timestamp == -2 and e0.is_keyframe + assert e1.timestamp == -1 and not e1.is_keyframe + assert e21.timestamp == 19 and e21.is_keyframe + + def test_index_entries_matches_packet_mp4(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + for i, packet in enumerate(container.demux(video=0)): + if packet.dts is not None: + assert stream.index_entries[i].timestamp == packet.dts + + def test_index_entries_in_bounds(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + first = stream.index_entries[0] + first_neg = stream.index_entries[-len(stream.index_entries)] + last = stream.index_entries[-1] + last_pos = stream.index_entries[len(stream.index_entries) - 1] + assert first.timestamp == first_neg.timestamp + assert last.timestamp == last_pos.timestamp + + def test_index_entries_out_of_bounds(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + with self.assertRaises(IndexError): + _ = stream.index_entries[len(stream.index_entries)] + + with self.assertRaises(IndexError): + _ = stream.index_entries[-len(stream.index_entries) - 1] + + def test_index_entries_slice(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + + individual_indices = [stream.index_entries[i] for i in range(1, 5)] + slice_indices = stream.index_entries[1:5] + assert len(individual_indices) == len(slice_indices) == 4 + assert all( + [ + i.timestamp == j.timestamp + for i, j in zip(individual_indices, slice_indices) + ] + ) diff --git a/tests/test_logging.py b/tests/test_logging.py index 1747f40ee..c8c705b1c 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,5 +1,3 @@ -from __future__ import division - import errno import logging import threading @@ -7,78 +5,81 @@ import av.error import av.logging -from .common import TestCase + +def do_log(message: str) -> None: + av.logging.log(av.logging.INFO, "test", message) + + +def test_adapt_level() -> None: + assert av.logging.adapt_level(av.logging.ERROR) == logging.ERROR + assert av.logging.adapt_level(av.logging.WARNING) == logging.WARNING + assert ( + av.logging.adapt_level((av.logging.WARNING + av.logging.ERROR) // 2) + == logging.WARNING + ) -def do_log(message): - av.logging.log(av.logging.INFO, 'test', message) +def test_threaded_captures() -> None: + av.logging.set_level(av.logging.VERBOSE) + with av.logging.Capture(local=True) as logs: + do_log("main") + thread = threading.Thread(target=do_log, args=("thread",)) + thread.start() + thread.join() -class TestLogging(TestCase): + assert (av.logging.INFO, "test", "main") in logs + av.logging.set_level(None) - def test_adapt_level(self): - self.assertEqual( - av.logging.adapt_level(av.logging.ERROR), - logging.ERROR - ) - self.assertEqual( - av.logging.adapt_level(av.logging.WARNING), - logging.WARNING - ) - self.assertEqual( - av.logging.adapt_level((av.logging.WARNING + av.logging.ERROR) // 2), - logging.WARNING - ) - def test_threaded_captures(self): +def test_global_captures() -> None: + av.logging.set_level(av.logging.VERBOSE) - with av.logging.Capture(local=True) as logs: - do_log('main') - thread = threading.Thread(target=do_log, args=('thread', )) - thread.start() - thread.join() + with av.logging.Capture(local=False) as logs: + do_log("main") + thread = threading.Thread(target=do_log, args=("thread",)) + thread.start() + thread.join() - self.assertIn((av.logging.INFO, 'test', 'main'), logs) + assert (av.logging.INFO, "test", "main") in logs + assert (av.logging.INFO, "test", "thread") in logs + av.logging.set_level(None) - def test_global_captures(self): - with av.logging.Capture(local=False) as logs: - do_log('main') - thread = threading.Thread(target=do_log, args=('thread', )) - thread.start() - thread.join() +def test_repeats() -> None: + av.logging.set_level(av.logging.VERBOSE) - self.assertIn((av.logging.INFO, 'test', 'main'), logs) - self.assertIn((av.logging.INFO, 'test', 'thread'), logs) + with av.logging.Capture() as logs: + do_log("foo") + do_log("foo") + do_log("bar") + do_log("bar") + do_log("bar") + do_log("baz") - def test_repeats(self): + logs = [log for log in logs if log[1] == "test"] - with av.logging.Capture() as logs: - do_log('foo') - do_log('foo') - do_log('bar') - do_log('bar') - do_log('bar') - do_log('baz') + assert logs == [ + (av.logging.INFO, "test", "foo"), + (av.logging.INFO, "test", "foo"), + (av.logging.INFO, "test", "bar"), + (av.logging.INFO, "test", "bar (repeated 2 more times)"), + (av.logging.INFO, "test", "baz"), + ] - logs = [log for log in logs if log[1] == 'test'] + av.logging.set_level(None) - self.assertEqual(logs, [ - (av.logging.INFO, 'test', 'foo'), - (av.logging.INFO, 'test', 'foo'), - (av.logging.INFO, 'test', 'bar'), - (av.logging.INFO, 'test', 'bar (repeated 2 more times)'), - (av.logging.INFO, 'test', 'baz'), - ]) +def test_error() -> None: + av.logging.set_level(av.logging.VERBOSE) - def test_error(self): + log = (av.logging.ERROR, "test", "This is a test.") + av.logging.log(*log) + try: + av.error.err_check(-errno.EPERM) + except av.error.PermissionError as e: + assert e.log == log + else: + assert False - log = (av.logging.ERROR, 'test', 'This is a test.') - av.logging.log(*log) - try: - av.error.err_check(-errno.EPERM) - except OSError as e: - self.assertEqual(e.log, log) - else: - self.fail() + av.logging.set_level(None) diff --git a/tests/test_open.py b/tests/test_open.py new file mode 100644 index 000000000..60cb60072 --- /dev/null +++ b/tests/test_open.py @@ -0,0 +1,55 @@ +import gc +import io +from pathlib import Path + +import av + +from .common import fate_suite + + +def test_path_input() -> None: + path = Path(fate_suite("h264/interlaced_crop.mp4")) + assert isinstance(path, Path) + + container = av.open(path) + assert type(container) is av.container.InputContainer + + +def test_str_input() -> None: + path = fate_suite("h264/interlaced_crop.mp4") + assert type(path) is str + + container = av.open(path) + assert type(container) is av.container.InputContainer + + +def test_path_output() -> None: + path = Path(fate_suite("h264/interlaced_crop.mp4")) + assert isinstance(path, Path) + + container = av.open(path, "w") + assert type(container) is av.container.OutputContainer + + +def test_str_output() -> None: + path = fate_suite("h264/interlaced_crop.mp4") + assert type(path) is str + + container = av.open(path, "w") + assert type(container) is av.container.OutputContainer + + +def _container_no_close() -> None: + buf = io.BytesIO() + container = av.open(buf, mode="w", format="mp4") + stream = container.add_stream("mpeg4", rate=24) + stream.width = 320 + stream.height = 240 + stream.pix_fmt = "yuv420p" + container.start_encoding() + + +def test_container_no_close() -> None: + # Do not close so that container is freed through GC. + _container_no_close() + gc.collect() diff --git a/tests/test_options.py b/tests/test_options.py deleted file mode 100644 index ff161e8a1..000000000 --- a/tests/test_options.py +++ /dev/null @@ -1,21 +0,0 @@ -from av import ContainerFormat -from av.option import Option, OptionType - -from .common import TestCase - - -class TestOptions(TestCase): - - def test_mov_options(self): - - mov = ContainerFormat('mov') - options = mov.descriptor.options - by_name = {opt.name: opt for opt in options} - - opt = by_name.get('use_absolute_path') - - self.assertIsInstance(opt, Option) - self.assertEqual(opt.name, 'use_absolute_path') - - # This was not a good option to actually test. - self.assertIn(opt.type, (OptionType.BOOL, OptionType.INT)) diff --git a/tests/test_packet.py b/tests/test_packet.py new file mode 100644 index 000000000..e266bbe3c --- /dev/null +++ b/tests/test_packet.py @@ -0,0 +1,261 @@ +import fractions +import io +import json +import struct +from typing import get_args +from unittest import SkipTest + +import numpy +import pytest + +import av + +from .common import fate_suite, sandboxed + + +class TestDataStreams: + def generate_container_with_data_packets(self): + file = io.BytesIO() + packet_datas_expected = dict[fractions.Fraction, bytes]() + + container = av.open(file, format="mp4", mode="w") + stream = container.add_data_stream("bin_data") + vstream = container.add_stream("h264") + + for i in range(10): + packet_data = json.dumps("This may not work", ensure_ascii=False).encode( + "utf-8" + ) + if i % 2 == 0: + packet_data = b"This works fine" + packet = av.Packet(packet_data) + vframe = av.VideoFrame.from_ndarray( + numpy.random.randint(0, 255, size=(120, 120, 3), dtype=numpy.uint8) + ) + packet.pts = i + packet.dts = i + packet.duration = 1 + packet.stream = stream + container.mux(packet) + container.mux(vstream.encode(vframe)) + # NOTE test passes if this is used + # packet_datas_expected[packet.pts * packet.time_base] = bytes(packet_data) + + container.close() + + container = av.open(file, format="mp4", mode="r") + return container, packet_datas_expected + + def test_data_packet_bytes(self): + container, packet_datas_expected = self.generate_container_with_data_packets() + + packet_datas = dict[fractions.Fraction, bytes]() + for packet in container.demux(container.streams.data[0]): + if packet.pts is None: + continue + assert bytes(packet) in ( + b"This works fine", + json.dumps("This may not work", ensure_ascii=False).encode("utf-8"), + ), bytes(packet) + # NOTE test passes if this is used + # packet_datas[packet.pts * packet.time_base] = bytes(packet) + + assert packet_datas == packet_datas_expected + + +class TestProperties: + def test_rescale_ts(self) -> None: + packet = av.Packet() + packet.time_base = fractions.Fraction(1, 1000) + packet.pts = 1000 + packet.dts = 900 + packet.duration = 40 + + packet.rescale_ts(av.AVRational(1, 100)) + + assert packet.time_base == fractions.Fraction(1, 100) + assert packet.pts == 100 + assert packet.dts == 90 + assert packet.duration == 4 + + def test_rescale_ts_without_source_time_base(self) -> None: + packet = av.Packet() + packet.pts = 1000 + packet.dts = 900 + packet.duration = 40 + + packet.rescale_ts(av.AVRational(1, 100)) + + assert packet.time_base == fractions.Fraction(1, 100) + assert packet.pts == 1000 + assert packet.dts == 900 + assert packet.duration == 40 + + def test_rescale_ts_rejects_zero_time_base(self) -> None: + packet = av.Packet() + + with pytest.raises(ValueError, match="Cannot rebase to zero time"): + packet.rescale_ts(av.AVRational(0, 1)) + + def test_rescale_ts_requires_avrational(self) -> None: + packet = av.Packet() + + with pytest.raises(TypeError, match="time_base must be an AVRational"): + packet.rescale_ts(fractions.Fraction(1, 100)) # type: ignore[arg-type] + + def test_is_keyframe(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + for i, packet in enumerate(container.demux(stream)): + if i in (0, 21, 45, 69, 93, 117): + assert packet.is_keyframe + else: + assert not packet.is_keyframe + + def test_is_corrupt(self) -> None: + with av.open(fate_suite("mov/white_zombie_scrunch-part.mov")) as container: + stream = container.streams.video[0] + for i, packet in enumerate(container.demux(stream)): + if i == 65: + assert packet.is_corrupt + else: + assert not packet.is_corrupt + + def test_is_discard(self) -> None: + with av.open(fate_suite("mov/mov-1elist-ends-last-bframe.mov")) as container: + stream = container.streams.video[0] + for i, packet in enumerate(container.demux(stream)): + if i == 46: + assert packet.is_discard + else: + assert not packet.is_discard + + def test_is_disposable(self) -> None: + with av.open(fate_suite("hap/HAPQA_NoSnappy_127x1.mov")) as container: + stream = container.streams.video[0] + for i, packet in enumerate(container.demux(stream)): + if i == 0: + assert packet.is_disposable + else: + assert not packet.is_disposable + + def test_set_duration(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + for packet in container.demux(): + assert packet.duration is not None + old_duration = packet.duration + packet.duration += 10 + + assert packet.duration == old_duration + 10 + + +class TestPacketSideData: + def test_data_types(self) -> None: + dtypes = get_args(av.packet.PktSideDataT) + + if av.ffmpeg_version_info.startswith("n") or av.ffmpeg_version_info.count( + "." + ) not in (1, 2): + raise SkipTest(f"Expect version to be SemVar: {av.ffmpeg_version_info}") + + ffmpeg_ver = [int(v) for v in av.ffmpeg_version_info.split(".", 2)[:2]] + for dtype in dtypes: + av_enum = av.packet.packet_sidedata_type_from_literal(dtype) + assert dtype == av.packet.packet_sidedata_type_to_literal(av_enum) + + if (ffmpeg_ver[0] < 8 and dtype == "lcevc") or ( + ffmpeg_ver[0] < 9 and dtype == "rtcp_sr" + ): + break + + def test_iter(self) -> None: + with av.open(fate_suite("h264/extradata-reload-multi-stsd.mov")) as container: + for pkt in container.demux(): + for sdata in pkt.iter_sidedata(): + assert pkt.dts == 2 and sdata.data_type == "new_extradata" + + def test_palette(self) -> None: + with av.open(fate_suite("h264/extradata-reload-multi-stsd.mov")) as container: + iterpackets = container.demux() + pkt = next(pkt for pkt in iterpackets if pkt.has_sidedata("new_extradata")) + + sdata = pkt.get_sidedata("new_extradata") + assert sdata.data_type == "new_extradata" + assert bool(sdata) + assert sdata.data_size > 0 + assert sdata.data_desc == "New Extradata" + + nxt = next(iterpackets) # has no palette + + assert not nxt.has_sidedata("new_extradata") + + sdata1 = nxt.get_sidedata("new_extradata") + assert sdata1.data_type == "new_extradata" + assert not bool(sdata1) + assert sdata1.data_size == 0 + + nxt.set_sidedata(sdata, move=True) + assert not bool(sdata) + + def test_buffer_protocol(self) -> None: + with av.open(fate_suite("h264/extradata-reload-multi-stsd.mov")) as container: + for pkt in container.demux(): + if pkt.has_sidedata("new_extradata"): + sdata = pkt.get_sidedata("new_extradata") + + raw = bytes(sdata) + assert len(raw) == sdata.data_size > 0 + assert sdata.buffer_size == sdata.data_size + assert sdata.buffer_ptr != 0 + assert bytes(memoryview(sdata)) == raw + + # Modify and verify changes stick + sdata.update(b"\xde\xad\xbe\xef" + raw[4:]) + assert bytes(sdata)[:4] == b"\xde\xad\xbe\xef" + + pkt.set_sidedata(sdata) + assert ( + bytes(pkt.get_sidedata("new_extradata"))[:4] + == b"\xde\xad\xbe\xef" + ) + return + + raise AssertionError("No packet with new_extradata side data found") + + def test_skip_samples_remux(self) -> None: + # Source file has skip_end=706 on last packet. Setting to 0 should + # result in 706 more decoded samples. And the file duration reported by + # the container should also increase. + output_path = sandboxed("skip_samples_modified.mkv") + + with av.open(fate_suite("mkv/codec_delay_opus.mkv")) as c: + original_samples = sum(f.samples for f in c.decode(c.streams.audio[0])) + + with av.open(fate_suite("mkv/codec_delay_opus.mkv")) as inp: + original_duration = inp.duration + audio_stream = inp.streams.audio[0] + with av.open(output_path, "w") as out: + out_stream = out.add_stream_from_template(audio_stream) + for pkt in inp.demux(audio_stream): + if pkt.size == 0: + continue + if pkt.has_sidedata("skip_samples"): + sdata = pkt.get_sidedata("skip_samples") + raw = bytes(sdata) + skip_end = struct.unpack(" 0: + assert skip_end == 706 + sdata.update(raw[:4] + struct.pack(" original_duration diff --git a/tests/test_python_io.py b/tests/test_python_io.py index f622bdb65..a56196bde 100644 --- a/tests/test_python_io.py +++ b/tests/test_python_io.py @@ -1,19 +1,72 @@ -from __future__ import division +from __future__ import annotations + +import functools +import io +import types +from io import BytesIO +from re import escape +from typing import TYPE_CHECKING + +import pytest import av -from .common import MethodLogger, TestCase, fate_suite +from .common import TestCase, fate_png, fate_suite, has_pillow from .test_encode import assert_rgb_rotate, write_rgb_rotate +if TYPE_CHECKING: + from collections.abc import Callable + + +class MethodLogger: + def __init__(self, obj: object) -> None: + self._obj = obj + self._log: list[tuple[str, object]] = [] + + def __getattr__(self, name: str) -> object: + def _method(name: str, meth: Callable, *args, **kwargs) -> object: + self._log.append((name, args)) + return meth(*args, **kwargs) + + value = getattr(self._obj, name) + if isinstance( + value, + ( + types.MethodType, + types.FunctionType, + types.BuiltinFunctionType, + types.BuiltinMethodType, + ), + ): + return functools.partial(_method, name, value) + else: + self._log.append(("__getattr__", (name,))) + return value + + def _filter(self, type_: str) -> list[tuple[str, object]]: + return [log for log in self._log if log[0] == type_] -try: - from cStringIO import StringIO -except ImportError: - from io import BytesIO as StringIO +class BrokenBuffer(BytesIO): + """ + Buffer which can be "broken" to simulate an I/O error. + """ -class NonSeekableBuffer: - def __init__(self, data): + broken = False + + def write(self, data): + if self.broken: + raise OSError("It's broken") + else: + return super().write(data) + + +class ReadOnlyBuffer: + """ + Minimal buffer which *only* implements the read() method. + """ + + def __init__(self, data) -> None: self.data = data def read(self, n): @@ -22,77 +75,257 @@ def read(self, n): return data -class TestPythonIO(TestCase): +class ReadOnlyPipe(BytesIO): + """ + Buffer which behaves like a readable pipe. + """ + + @property + def name(self) -> int: + return 123 + + def seekable(self) -> bool: + return False + + def writable(self) -> bool: + return False + + +class WriteOnlyPipe(BytesIO): + """ + Buffer which behaves like a writable pipe. + """ + + @property + def name(self) -> int: + return 123 + + def readable(self) -> bool: + return False + + def seekable(self) -> bool: + return False + + +def read( + fh: io.BufferedReader | BytesIO | ReadOnlyBuffer, seekable: bool = True +) -> None: + wrapped = MethodLogger(fh) + + with av.open(wrapped, "r") as container: + assert container.format.name == "mpegts" + assert container.format.long_name == "MPEG-TS (MPEG-2 Transport Stream)" + assert len(container.streams) == 1 + if seekable: + assert container.size == 800000 + assert container.metadata == {} + + # Check method calls. + assert wrapped._filter("read") + if seekable: + assert wrapped._filter("seek") + + +def write(fh: io.BufferedWriter | BytesIO) -> None: + wrapped = MethodLogger(fh) + + with av.open(wrapped, "w", "mp4") as container: + write_rgb_rotate(container) + + # Check method calls. + assert wrapped._filter("write") + assert wrapped._filter("seek") - def test_reading(self): - with open(fate_suite('mpeg2/mpeg2_field_encoding.ts'), 'rb') as fh: - wrapped = MethodLogger(fh) +# Using a custom protocol will avoid the DASH muxer detecting or defaulting to a +# file: protocol and enabling the use of temporary files and renaming. +CUSTOM_IO_PROTOCOL = "pyavtest://" - container = av.open(wrapped) - self.assertEqual(container.format.name, 'mpegts') - self.assertEqual(container.format.long_name, "MPEG-TS (MPEG-2 Transport Stream)") - self.assertEqual(len(container.streams), 1) - self.assertEqual(container.size, 800000) - self.assertEqual(container.metadata, {}) +class CustomIOLogger: + """Log calls to open a file as well as method calls on the files""" - # Make sure it did actually call "read". - reads = wrapped._filter('read') - self.assertTrue(reads) + def __init__(self) -> None: + self._log: list[tuple[object, dict]] = [] + self._method_log: list[MethodLogger] = [] - def test_reading_no_seek(self): - with open(fate_suite('mpeg2/mpeg2_field_encoding.ts'), 'rb') as fh: - data = fh.read() + def __call__(self, *args, **kwargs) -> MethodLogger: + self._log.append((args, kwargs)) + self._method_log.append(self.io_open(*args, **kwargs)) + return self._method_log[-1] - buf = NonSeekableBuffer(data) - wrapped = MethodLogger(buf) + def io_open(self, url: str, flags, options: object) -> MethodLogger: + # Remove the protocol prefix to reveal the local filename + if CUSTOM_IO_PROTOCOL in url: + url = url.split(CUSTOM_IO_PROTOCOL, 1)[1] - container = av.open(wrapped) + if (flags & 3) == 3: + mode = "r+b" + elif (flags & 1) == 1: + mode = "rb" + elif (flags & 2) == 2: + mode = "wb" + else: + raise RuntimeError(f"Unsupported io open mode {flags}") - self.assertEqual(container.format.name, 'mpegts') - self.assertEqual(container.format.long_name, "MPEG-TS (MPEG-2 Transport Stream)") - self.assertEqual(len(container.streams), 1) - self.assertEqual(container.metadata, {}) + return MethodLogger(open(url, mode)) - # Make sure it did actually call "read". - reads = wrapped._filter('read') - self.assertTrue(reads) - def test_basic_errors(self): +class TestPythonIO(TestCase): + def test_basic_errors(self) -> None: self.assertRaises(Exception, av.open, None) - self.assertRaises(Exception, av.open, None, 'w') + self.assertRaises(Exception, av.open, None, "w") + + def test_reading_from_buffer(self) -> None: + with open(fate_suite("mpeg2/mpeg2_field_encoding.ts"), "rb") as fh: + buf = BytesIO(fh.read()) + read(buf, seekable=True) + + def test_reading_from_buffer_no_seek(self) -> None: + with open(fate_suite("mpeg2/mpeg2_field_encoding.ts"), "rb") as fh: + buf = ReadOnlyBuffer(fh.read()) + read(buf, seekable=False) + + def test_reading_from_file(self) -> None: + with open(fate_suite("mpeg2/mpeg2_field_encoding.ts"), "rb") as fh: + read(fh, seekable=True) + + def test_reading_from_pipe_readonly(self) -> None: + with open(fate_suite("mpeg2/mpeg2_field_encoding.ts"), "rb") as fh: + buf = ReadOnlyPipe(fh.read()) + read(buf, seekable=False) + + def test_reading_from_write_readonly(self) -> None: + with open(fate_suite("mpeg2/mpeg2_field_encoding.ts"), "rb") as fh: + buf = WriteOnlyPipe(fh.read()) + + msg = escape("File object has no read() method, or readable() returned False.") + with pytest.raises(ValueError, match=msg): + read(buf, seekable=False) + + def test_writing_to_buffer(self) -> None: + buf = BytesIO() + + write(buf) + + # Check contents. + assert buf.tell() + buf.seek(0) + with av.open(buf, "r") as container: + assert_rgb_rotate(self, container) + + def test_writing_to_buffer_broken(self) -> None: + buf = BrokenBuffer() + + with pytest.raises(OSError): + with av.open(buf, "w", "mp4") as container: + write_rgb_rotate(container) + + # break I/O + buf.broken = True + + def test_writing_to_buffer_broken_with_close(self) -> None: + buf = BrokenBuffer() + + with av.open(buf, "w", "mp4") as container: + write_rgb_rotate(container) + + # break I/O + buf.broken = True + + # try to close file + with pytest.raises(OSError): + container.close() + + def test_writing_to_custom_io_image2(self) -> None: + if not has_pillow: + pytest.skip() + + import PIL.Image as Image + + # Custom I/O that opens file and logs calls + wrapped_custom_io = CustomIOLogger() + + image = Image.open(fate_png()) + input_frame = av.VideoFrame.from_image(image) + + frame_count = 10 + sequence_filename = self.sandboxed("test%d.png") + width = 160 + height = 90 + + # Write a PNG image sequence using the custom IO + with av.open( + sequence_filename, "w", "image2", io_open=wrapped_custom_io + ) as output: + stream = output.add_stream("png") + stream.width = width + stream.height = height + stream.pix_fmt = "rgb24" + + for _ in range(frame_count): + for packet in stream.encode(input_frame): + output.mux(packet) + + # Check that "frame_count" files were opened using the custom IO + assert len(wrapped_custom_io._log) == frame_count + assert len(wrapped_custom_io._method_log) == frame_count + + # Check that all files were written to + all_write = all( + method_log._filter("write") for method_log in wrapped_custom_io._method_log + ) + assert all_write + + # Check that all files were closed + all_closed = all( + method_log._filter("close") for method_log in wrapped_custom_io._method_log + ) + assert all_closed - def test_writing(self): + # Check contents. + with av.open(sequence_filename, "r", "image2") as container: + assert len(container.streams) == 1 + assert isinstance(container.streams[0], av.video.stream.VideoStream) - path = self.sandboxed('writing.mov') - with open(path, 'wb') as fh: - wrapped = MethodLogger(fh) + stream = container.streams[0] + assert stream.duration == frame_count + assert stream.type == "video" - output = av.open(wrapped, 'w', 'mov') - write_rgb_rotate(output) - output.close() - fh.close() + # codec context properties + assert stream.codec.name == "png" + assert stream.format.name == "rgb24" + assert stream.format.width == width + assert stream.format.height == height - # Make sure it did actually write. - writes = wrapped._filter('write') - self.assertTrue(writes) + def test_writing_to_file(self) -> None: + path = self.sandboxed("writing.mp4") - # Standard assertions. - assert_rgb_rotate(self, av.open(path)) + with open(path, "wb") as fh: + write(fh) - def test_buffer_read_write(self): + # Check contents. + with av.open(path) as container: + assert_rgb_rotate(self, container) - buffer_ = StringIO() - wrapped = MethodLogger(buffer_) - write_rgb_rotate(av.open(wrapped, 'w', 'mp4')) + def test_writing_to_pipe_readonly(self) -> None: + buf = ReadOnlyPipe() + with pytest.raises( + ValueError, + match=escape( + "File object has no write() method, or writable() returned False." + ), + ) as cm: + write(buf) - # Make sure it did actually write. - writes = wrapped._filter('write') - self.assertTrue(writes) + def test_writing_to_pipe_writeonly(self) -> None: + av.logging.set_level(av.logging.VERBOSE) - self.assertTrue(buffer_.tell()) + buf = WriteOnlyPipe() + with pytest.raises( + av.ArgumentError, + match=escape("[mp4] muxer does not support non seekable output"), + ): + write(buf) - # Standard assertions. - buffer_.seek(0) - assert_rgb_rotate(self, av.open(buffer_)) + av.logging.set_level(None) diff --git a/tests/test_rational.py b/tests/test_rational.py new file mode 100644 index 000000000..f534e7d68 --- /dev/null +++ b/tests/test_rational.py @@ -0,0 +1,88 @@ +import pickle +from fractions import Fraction + +import pytest + +from av import AVRational + + +def test_construction() -> None: + r = AVRational(2, 4) + assert (r.num, r.den) == (1, 2) + assert (AVRational(1, -2).num, AVRational(1, -2).den) == (-1, 2) + assert AVRational(Fraction(30000, 1001)) == AVRational(30000, 1001) + assert AVRational(10**10, 2 * 10**10) == AVRational(1, 2) + with pytest.raises(OverflowError): + AVRational(2**31, 3) + + +def test_unset_is_falsy() -> None: + assert not AVRational() + assert not AVRational(0, 1) + assert AVRational(1, 25) + + +def test_zero_denominator() -> None: + inf = AVRational(1, 0) + assert not inf and not AVRational(-1, 0) and not AVRational(0, 0) + assert (AVRational(5, 0).num, AVRational(5, 0).den) == (1, 0) + assert (AVRational(-7, 0).num, AVRational(-7, 0).den) == (-1, 0) + assert inf == AVRational(2, 0) + assert inf != AVRational(0, 0) and inf != Fraction(1, 2) and inf != 1 + assert float(inf) == float("inf") + assert float(AVRational(-1, 0)) == float("-inf") + assert str(float(AVRational(0, 0))) == "nan" + assert hash(inf) == hash(AVRational(1, 0)) + assert pickle.loads(pickle.dumps(inf)) == inf + + +def test_fraction_interop() -> None: + r = AVRational(1, 2) + assert r == Fraction(1, 2) + assert Fraction(1, 2) == r + assert hash(r) == hash(Fraction(1, 2)) + assert r < Fraction(2, 3) < AVRational(3, 4) + assert r * Fraction(1, 3) == Fraction(1, 6) + assert Fraction(1, 3) * r == Fraction(1, 6) + assert 2 * r == 1 + assert r + 1 == Fraction(3, 2) + assert 1 - r == Fraction(1, 2) + assert float(r) == 0.5 + + +def test_avrational_arithmetic() -> None: + a = AVRational(1, 25) + b = AVRational(1, 2) + assert a * b == AVRational(1, 50) + assert isinstance(a * b, AVRational) + assert a + b == AVRational(27, 50) + assert b - a == AVRational(23, 50) + assert a / b == AVRational(2, 25) + assert -a == AVRational(-1, 25) + with pytest.raises(ZeroDivisionError): + a / AVRational(0, 1) + huge = AVRational(1, 2**30) * AVRational(1, 2**30) + assert float(huge) == pytest.approx(2.0**-60, rel=1e-6) + + +def test_setters_accept_avrational() -> None: + import av + + cc = av.codec.CodecContext.create("mpeg4", "w") + cc.time_base = AVRational(1001, 30000) # type: ignore[assignment] + assert cc.time_base == Fraction(1001, 30000) + + +def test_codec_frame_rates() -> None: + import av + + rates = av.Codec("mpeg2video", "w").frame_rates + assert rates and all(isinstance(r, AVRational) for r in rates) + assert AVRational(30000, 1001) in rates + + +def test_pickle_and_repr() -> None: + r = AVRational(30000, 1001) + assert pickle.loads(pickle.dumps(r)) == r + assert repr(r) == "AVRational(30000, 1001)" + assert str(r) == "30000/1001" diff --git a/tests/test_remux.py b/tests/test_remux.py new file mode 100644 index 000000000..c5045e979 --- /dev/null +++ b/tests/test_remux.py @@ -0,0 +1,232 @@ +import io +from fractions import Fraction + +import numpy as np +import pytest + +import av +import av.datasets + +from .common import fate_suite, sandboxed + + +def test_video_remux() -> None: + input_path = av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") + output_path = sandboxed("remuxed.mkv") + input_ = av.open(input_path) + output = av.open(output_path, "w") + + in_stream = input_.streams.video[0] + out_stream = output.add_stream_from_template(in_stream) + + for packet in input_.demux(in_stream): + if packet.size == 0: # skip the flushing packet, not keyframes with no DTS + continue + + packet.stream = out_stream + output.mux(packet) + + input_.close() + output.close() + + with av.open(output_path) as container: + # Assert output is a valid media file + assert len(container.streams.video) == 1 + assert len(container.streams.audio) == 0 + assert container.streams.video[0].codec.name == "h264" + + packet_count = 0 + for packet in container.demux(video=0): + packet_count += 1 + + assert packet_count > 50 + + +def test_add_mux_stream_video() -> None: + """add_mux_stream creates a video stream without a CodecContext.""" + input_path = av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") + + buf = io.BytesIO() + with av.open(input_path) as input_: + in_stream = input_.streams.video[0] + width = in_stream.codec_context.width + height = in_stream.codec_context.height + + with av.open(buf, "w", format="mp4") as output: + out_stream = output.add_mux_stream( + in_stream.codec_context.name, width=width, height=height + ) + assert out_stream.codec_context is None + assert out_stream.type == "video" + + out_stream.time_base = in_stream.time_base + + for packet in input_.demux(in_stream): + if packet.size == 0: + continue + packet.stream = out_stream + output.mux(packet) + + buf.seek(0) + with av.open(buf) as result: + assert len(result.streams.video) == 1 + assert result.streams.video[0].codec_context.width == width + assert result.streams.video[0].codec_context.height == height + + +def test_add_mux_stream_no_codec_context() -> None: + """add_mux_stream streams have no codec context and repr does not crash.""" + buf = io.BytesIO() + with av.open(buf, "w", format="mp4") as output: + video_stream = output.add_mux_stream("h264", width=1920, height=1080) + audio_stream = output.add_mux_stream("aac", rate=44100) + + assert video_stream.codec_context is None + assert audio_stream.codec_context is None + # repr should not crash + assert "video/" in repr(video_stream) + assert "audio/" in repr(audio_stream) + + +def test_add_mux_stream_matroska_extradata() -> None: + """Regression test for #2198. + + Muxing pre-encoded H.264 packets into Matroska used to fail in + ``avformat_write_header`` because the muxer needs ``CodecPrivate`` (the codec + extradata) before the first packet is written. When the packets are annex-b + with in-band parameter sets but the stream has no extradata, + ``add_mux_stream`` extracts it from the bitstream so the file is written and + stays decodable. + """ + if av.codec.Codec("h264", "w").name != "libx264": + pytest.skip("requires libx264") + + # Encode without a global header, so the packets carry annex-b parameter + # sets in-band and the codec context exposes no extradata to copy. + cc = av.CodecContext.create("libx264", "w") + cc.width, cc.height, cc.pix_fmt = 320, 240, "yuv420p" + cc.time_base = Fraction(1, 24) + cc.framerate = Fraction(24, 1) + + packets = [] + for i in range(24): + frame = av.VideoFrame.from_ndarray( + np.zeros((240, 320, 3), dtype="uint8"), format="rgb24" + ) + frame.pts = i + frame.time_base = Fraction(1, 24) + packets.extend(cc.encode(frame)) + packets.extend(cc.encode(None)) + assert cc.extradata is None # nothing to copy onto the stream + + buf = io.BytesIO() + with av.open(buf, "w", format="matroska") as output: + out_stream = output.add_mux_stream("libx264", rate=24, width=320, height=240) + for packet in packets: + packet.stream = out_stream + output.mux(packet) + + assert _decoded_frame_count(buf) == 24 + + +def test_add_stream_from_template_copies_time_base() -> None: + """add_stream_from_template must propagate the source stream's time_base. + + AVCodecParameters does not carry time_base, so without an explicit copy + the output stream's time_base stays as None + """ + video_path = av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4") + with ( + av.open(video_path) as input_, + av.open(io.BytesIO(), "w", format="mp4") as output, + ): + in_video = input_.streams.video[0] + out_video = output.add_stream_from_template(in_video) + assert out_video.time_base is not None + assert out_video.time_base == in_video.time_base + + audio_path = fate_suite("audio-reference/chorusnoise_2ch_44kHz_s16.wav") + with ( + av.open(audio_path) as input_, + av.open(io.BytesIO(), "w", format="wav") as output, + ): + in_audio = input_.streams.audio[0] + out_audio = output.add_stream_from_template(in_audio) + assert out_audio.time_base is not None + assert out_audio.time_base == in_audio.time_base + + +def _make_b_frame_mkv(n: int = 48) -> io.BytesIO: + """Encode `n` frames with B-frames into an in-memory MKV. + + Matroska stores only presentation timestamps, so when this is demuxed again + libavformat cannot reconstruct a DTS for the leading reordered packets and + leaves it as None -- including on the very first packet, which is the + keyframe. That is exactly the layout that broke the remux example in #1917. + """ + buf = io.BytesIO() + with av.open(buf, "w", format="matroska") as out: + stream = out.add_stream("h264", rate=30) + stream.width, stream.height, stream.pix_fmt = 160, 120, "yuv420p" + stream.options = {"bf": "3", "g": "30"} + for i in range(n): + img = np.full((120, 160, 3), (i * 5) % 256, dtype="uint8") + frame = av.VideoFrame.from_ndarray(img, format="rgb24") + for packet in stream.encode(frame): + out.mux(packet) + for packet in stream.encode(None): + out.mux(packet) + buf.seek(0) + return buf + + +def _decoded_frame_count(buf: io.BytesIO) -> int: + buf.seek(0) + with av.open(buf, "r") as container: + return sum(1 for _ in container.decode(video=0)) + + +def test_remux_keeps_keyframe_with_none_dts() -> None: + """Regression test for #1917. + + A keyframe can legitimately demux with ``dts is None`` (B-frame stream in a + PTS-only container like MKV). The remux loop must skip only the empty + flushing packet (``size == 0``), not every ``dts is None`` packet, otherwise + the keyframe is dropped and the output is undecodable. + """ + if av.codec.Codec("h264", "w").name != "libx264": + pytest.skip("requires libx264") + + source = _make_b_frame_mkv() + expected_frames = _decoded_frame_count(source) + assert expected_frames > 0 + + # Precondition: the first packet really is a keyframe without a DTS, which is + # what the old `dts is None` filter would have wrongly discarded. + source.seek(0) + with av.open(source, "r") as input_: + first = next(p for p in input_.demux(input_.streams.video[0]) if p.size) + assert first.is_keyframe + assert first.dts is None + + source.seek(0) + output = io.BytesIO() + with ( + av.open(source, "r") as input_, + av.open(output, "w", format="matroska") as out, + ): + in_video = input_.streams.video[0] + out_video = out.add_stream_from_template(in_video) + for packet in input_.demux(in_video): + if packet.size == 0: # the flushing packet, not a keyframe with no DTS + continue + packet.stream = out_video + out.mux(packet) + + # The keyframe survived: every frame still decodes and the first packet of + # the remuxed stream is a keyframe. + assert _decoded_frame_count(output) == expected_frames + output.seek(0) + with av.open(output, "r") as container: + first_out = next(p for p in container.demux(video=0) if p.size) + assert first_out.is_keyframe diff --git a/tests/test_seek.py b/tests/test_seek.py index e482906df..ac28f6580 100644 --- a/tests/test_seek.py +++ b/tests/test_seek.py @@ -1,43 +1,29 @@ -from __future__ import division - -import unittest -import warnings - import av from .common import TestCase, fate_suite -def timestamp_to_frame(timestamp, stream): - fps = stream.rate +def timestamp_to_frame(timestamp: int, stream: av.video.stream.VideoStream) -> float: + fps = stream.average_rate time_base = stream.time_base start_time = stream.start_time - frame = (timestamp - start_time) * float(time_base) * float(fps) - return frame - - -def step_forward(container, stream): - for packet in container.demux(stream): - for frame in packet.decode(): - if frame: - return frame + assert time_base is not None and start_time is not None and fps is not None + return (timestamp - start_time) * float(time_base) * float(fps) class TestSeek(TestCase): - - def test_seek_float(self): - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_seek_float(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) self.assertRaises(TypeError, container.seek, 1.0) - self.assertRaises(TypeError, container.streams.video[0].seek, 1.0) - def test_seek_int64(self): + def test_seek_int64(self) -> None: # Assert that it accepts large values. # Issue 251 pointed this out. - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + container = av.open(fate_suite("h264/interlaced_crop.mp4")) container.seek(2**32) - def test_seek_start(self): - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_seek_start(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) # count all the packets total_packet_count = 0 @@ -52,10 +38,11 @@ def test_seek_start(self): for packet in container.demux(): seek_packet_count += 1 - self.assertEqual(total_packet_count, seek_packet_count) + assert total_packet_count == seek_packet_count - def test_seek_middle(self): - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_seek_middle(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + assert container.duration is not None # count all the packets total_packet_count = 0 @@ -69,10 +56,11 @@ def test_seek_middle(self): for packet in container.demux(): seek_packet_count += 1 - self.assertTrue(seek_packet_count < total_packet_count) + assert seek_packet_count < total_packet_count - def test_seek_end(self): - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_seek_end(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + assert container.duration is not None # seek to middle container.seek(container.duration // 2) @@ -89,26 +77,25 @@ def test_seek_end(self): seek_packet_count += 1 # there should be some packet because we're seeking to the last keyframe - self.assertTrue(seek_packet_count > 0) - self.assertTrue(seek_packet_count < middle_packet_count) - - def test_decode_half(self): + assert seek_packet_count > 0 + assert seek_packet_count < middle_packet_count - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_decode_half(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + video_stream = container.streams.video[0] - video_stream = next(s for s in container.streams if s.type == 'video') total_frame_count = 0 + for frame in container.decode(video_stream): + total_frame_count += 1 - # Count number of frames in video - for packet in container.demux(video_stream): - for frame in packet.decode(): - total_frame_count += 1 - - self.assertEqual(video_stream.frames, total_frame_count) + assert video_stream.frames == total_frame_count + assert video_stream.average_rate is not None # set target frame to middle frame - target_frame = int(total_frame_count / 2.0) - target_timestamp = int((target_frame * av.time_base) / video_stream.rate) + target_frame = total_frame_count // 2 + target_timestamp = int( + (target_frame * av.time_base) / video_stream.average_rate + ) # should seek to nearest keyframe before target_timestamp container.seek(target_timestamp) @@ -116,67 +103,51 @@ def test_decode_half(self): current_frame = None frame_count = 0 - for packet in container.demux(video_stream): - for frame in packet.decode(): - if current_frame is None: - current_frame = timestamp_to_frame(frame.pts, video_stream) - else: - current_frame += 1 + for frame in container.decode(video_stream): + assert frame.pts is not None - # start counting once we reach the target frame - if current_frame is not None and current_frame >= target_frame: - frame_count += 1 + if current_frame is None: + current_frame = timestamp_to_frame(frame.pts, video_stream) + else: + current_frame += 1 - self.assertEqual(frame_count, total_frame_count - target_frame) + # start counting once we reach the target frame + if current_frame is not None and current_frame >= target_frame: + frame_count += 1 - def test_stream_seek(self, use_deprecated_api=False): + assert frame_count == total_frame_count - target_frame - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_stream_seek(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + video_stream = container.streams.video[0] - video_stream = next(s for s in container.streams if s.type == 'video') - total_frame_count = 0 + assert video_stream.time_base is not None + assert video_stream.start_time is not None + assert video_stream.average_rate is not None - # Count number of frames in video - for packet in container.demux(video_stream): - for frame in packet.decode(): - total_frame_count += 1 + total_frame_count = 0 + for frame in container.decode(video_stream): + total_frame_count += 1 - target_frame = int(total_frame_count / 2.0) + target_frame = total_frame_count // 2 time_base = float(video_stream.time_base) - - rate = float(video_stream.average_rate) - target_sec = target_frame * 1 / rate + target_sec = target_frame * 1 / float(video_stream.average_rate) target_timestamp = int(target_sec / time_base) + video_stream.start_time - - if use_deprecated_api: - with warnings.catch_warnings(record=True) as captured: - video_stream.seek(target_timestamp) - self.assertEqual(len(captured), 1) - self.assertIn('Stream.seek is deprecated.', captured[0].message.args[0]) - else: - container.seek(target_timestamp, stream=video_stream) + container.seek(target_timestamp, stream=video_stream) current_frame = None frame_count = 0 - for packet in container.demux(video_stream): - for frame in packet.decode(): - if current_frame is None: - current_frame = timestamp_to_frame(frame.pts, video_stream) - - else: - current_frame += 1 - - # start counting once we reach the target frame - if current_frame is not None and current_frame >= target_frame: - frame_count += 1 - - self.assertEqual(frame_count, total_frame_count - target_frame) - - def test_deprecated_stream_seek(self): - self.test_stream_seek(use_deprecated_api=True) + for frame in container.decode(video_stream): + if current_frame is None: + assert frame.pts is not None + current_frame = timestamp_to_frame(frame.pts, video_stream) + else: + current_frame += 1 + # start counting once we reach the target frame + if current_frame is not None and current_frame >= target_frame: + frame_count += 1 -if __name__ == "__main__": - unittest.main() + assert frame_count == total_frame_count - target_frame diff --git a/tests/test_streams.py b/tests/test_streams.py index c108a8447..9e4941c3a 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1,30 +1,409 @@ +import io +import os +import struct + +import pytest + import av +import av.datasets + +from .common import fate_suite + + +def _crc32_mpeg(data: bytes) -> int: + crc = 0xFFFFFFFF + for byte in data: + crc ^= byte << 24 + for _ in range(8): + if crc & 0x80000000: + crc = (crc << 1) ^ 0x04C11DB7 + else: + crc <<= 1 + crc &= 0xFFFFFFFF + return crc + + +def _make_ts_packet(pid: int, payload: bytes, pusi: bool = False, cc: int = 0) -> bytes: + pid_hi = (pid >> 8) & 0x1F + pid_lo = pid & 0xFF + if pusi: + pid_hi |= 0x40 + header = bytes([0x47, pid_hi, pid_lo, 0x10 | (cc & 0x0F)]) + pkt = header + payload + return (pkt + b"\xff" * (188 - len(pkt)))[:188] + + +def _make_unknown_stream_ts() -> bytes: + """Build a minimal MPEG-TS byte string whose only stream has AVMEDIA_TYPE_UNKNOWN. -from .common import TestCase, fate_suite + Uses stream_type=0x82 in the PMT, which FFmpeg does not map to any known + media type, resulting in ``stream.type == "unknown"``. + """ + pat_data = bytes( + [0x00, 0xB0, 0x0D, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xE1, 0x00] + ) + pat_data += struct.pack(">I", _crc32_mpeg(pat_data)) + pat_payload = bytes([0x00]) + pat_data + # fmt: off + pmt_data = bytes([0x02, 0xB0, 0x12, 0x00, 0x01, 0xC1, 0x00, 0x00, + 0xE1, 0x02, 0xF0, 0x00, 0x82, 0xE1, 0x02, 0xF0, 0x00, + ]) + # fmt: on + pmt_data += struct.pack(">I", _crc32_mpeg(pmt_data)) + pmt_payload = bytes([0x00]) + pmt_data -class TestStreams(TestCase): + pes_header = bytes([0x00, 0x00, 0x01, 0xBD, 0x00, 0x0A, 0x80, 0x00, 0x00]) + pes_data = pes_header + b"\xaa" * (184 - len(pes_header)) - def test_stream_tuples(self): + packets: list[bytes] = [] + for i in range(2): + packets.append(_make_ts_packet(0x0000, pat_payload, pusi=True, cc=i)) + for i in range(2): + packets.append(_make_ts_packet(0x0100, pmt_payload, pusi=True, cc=i)) + for i in range(200): + packets.append(_make_ts_packet(0x0102, pes_data, pusi=(i % 10 == 0), cc=i % 16)) - for fate_name in ('h264/interlaced_crop.mp4', ): + return b"".join(packets) + +class TestStreams: + @pytest.fixture(autouse=True) + def cleanup(self): + yield + for file in ( + "data.ts", + "data_source.ts", + "data_copy.ts", + "data_with_codec.ts", + "data_invalid.ts", + "out.mkv", + "video_with_attachment.mkv", + "remuxed_attachment.mkv", + ): + if os.path.exists(file): + os.remove(file) + + def test_stream_tuples(self) -> None: + for fate_name in ("h264/interlaced_crop.mp4",): container = av.open(fate_suite(fate_name)) - video_streams = tuple([s for s in container.streams if s.type == 'video']) - self.assertEqual(video_streams, container.streams.video) + video_streams = tuple([s for s in container.streams if s.type == "video"]) + assert video_streams == container.streams.video + + audio_streams = tuple([s for s in container.streams if s.type == "audio"]) + assert audio_streams == container.streams.audio - audio_streams = tuple([s for s in container.streams if s.type == 'audio']) - self.assertEqual(audio_streams, container.streams.audio) + def test_loudnorm(self) -> None: + container = av.open( + fate_suite("amv/MTV_high_res_320x240_sample_Penguin_Joke_MTV_from_WMV.amv") + ) + audio = container.streams.audio[0] + stats = av.filter.loudnorm.stats("i=-24.0:lra=7.0:tp=-2.0", audio) - def test_selection(self): + assert isinstance(stats, bytes) and len(stats) > 30 + assert b"inf" not in stats + assert b'"input_i"' in stats - container = av.open(fate_suite('h264/interlaced_crop.mp4')) + def test_selection(self) -> None: + container = av.open( + fate_suite("amv/MTV_high_res_320x240_sample_Penguin_Joke_MTV_from_WMV.amv") + ) video = container.streams.video[0] - # audio_stream = container.streams.audio[0] - # audio_streams = list(container.streams.audio[0:2]) - self.assertEqual([video], container.streams.get(video=0)) - self.assertEqual([video], container.streams.get(video=(0, ))) + video.thread_type = av.codec.context.ThreadType.AUTO + assert video.thread_type == av.codec.context.ThreadType.AUTO + + video.thread_type = 0x03 + assert video.thread_type == av.codec.context.ThreadType.AUTO + + video.thread_type = "AUTO" + assert video.thread_type == av.codec.context.ThreadType.AUTO + + audio = container.streams.audio[0] + + assert [video] == container.streams.get(video=0) + assert [video] == container.streams.get(video=(0,)) + + assert video == container.streams.best("video") + assert audio == container.streams.best("audio") + + container = av.open(fate_suite("sub/MovText_capability_tester.mp4")) + subtitle = container.streams.subtitles[0] + assert subtitle == container.streams.best("subtitle") + + container = av.open(fate_suite("mxf/track_01_v02.mxf")) + data = container.streams.data[0] + assert data == container.streams.best("data") + + def test_discard(self) -> None: + from av.stream import Discard + + container = av.open( + fate_suite("amv/MTV_high_res_320x240_sample_Penguin_Joke_MTV_from_WMV.amv") + ) + audio = container.streams.audio[0] + + # Default discard policy. + assert audio.discard == Discard.default + + # Setter accepts the enum and round-trips. + audio.discard = Discard.all + assert audio.discard == Discard.all + + audio.discard = Discard.nonkey + assert audio.discard == Discard.nonkey + container.close() + + # Discarding a stream makes demux skip (almost) all of its packets. + def audio_packets(discard: Discard | None) -> int: + c = av.open( + fate_suite( + "amv/MTV_high_res_320x240_sample_Penguin_Joke_MTV_from_WMV.amv" + ) + ) + if discard is not None: + c.streams.audio[0].discard = discard + count = sum( + 1 for p in c.demux() if p.dts is not None and p.stream.type == "audio" + ) + c.close() + return count + + baseline = audio_packets(None) + discarded = audio_packets(Discard.all) + assert baseline > 0 + assert discarded < baseline + + def test_printing_video_stream(self) -> None: + input_ = av.open( + fate_suite("amv/MTV_high_res_320x240_sample_Penguin_Joke_MTV_from_WMV.amv") + ) + container = av.open("out.mkv", "w") + + video_stream = container.add_stream("h264", rate=30) + encoder = video_stream.codec.name + + video_stream.width = input_.streams.video[0].width + video_stream.height = input_.streams.video[0].height + video_stream.pix_fmt = "yuv420p" + + for frame in input_.decode(video=0): + container.mux(video_stream.encode(frame)) + break + + repr = f"{video_stream}" + assert repr.startswith(f"") + + container.close() + input_.close() + + def test_printing_video_stream2(self) -> None: + input_ = av.open(fate_suite("h264/interlaced_crop.mp4")) + input_stream = input_.streams.video[0] + container = av.open("out.mkv", "w") + + video_stream = container.add_stream_from_template(input_stream) + encoder = video_stream.codec.name + + for frame in input_.decode(video=0): + container.mux(video_stream.encode(frame)) + break + + repr = f"{video_stream}" + assert repr.startswith(f"") + + container.close() + input_.close() + + def test_data_stream(self) -> None: + # First test writing and reading a simple data stream + container1 = av.open("data.ts", "w") + data_stream = container1.add_data_stream() + + test_data = [b"test data 1", b"test data 2", b"test data 3"] + for i, data_ in enumerate(test_data): + packet = av.Packet(data_) + packet.pts = i + packet.stream = data_stream + container1.mux(packet) + container1.close() + + # Test reading back the data stream + container = av.open("data.ts") + + # Test best stream selection + data = container.streams.best("data") + assert data == container.streams.data[0] + + # Test get method + assert [data] == container.streams.get(data=0) + assert [data] == container.streams.get(data=(0,)) + + # Verify we can read back all the packets, ignoring empty ones + packets = [p for p in container.demux(data) if bytes(p)] + assert len(packets) == len(test_data) + for read_packet, original_data in zip(packets, test_data): + assert bytes(read_packet) == original_data + + # Test string representation + repr = f"{data_stream}" + assert repr.startswith("") + + container.close() + + def test_data_stream_from_template(self) -> None: + source_path = "data_source.ts" + payloads = [b"payload-a", b"payload-b", b"payload-c"] + + with av.open(source_path, "w") as source: + source_stream = source.add_data_stream() + for i, payload in enumerate(payloads): + packet = av.Packet(payload) + packet.pts = i + packet.stream = source_stream + source.mux(packet) + + copied_payloads: list[bytes] = [] + + with av.open(source_path) as input_container: + input_data_stream = input_container.streams.data[0] + + with av.open("data_copy.ts", "w") as output_container: + output_data_stream = output_container.add_stream_from_template( + input_data_stream + ) + + for data_packet in input_container.demux(input_data_stream): + payload = bytes(data_packet) + if not payload: + continue + copied_payloads.append(payload) + clone = av.Packet(payload) + clone.pts = data_packet.pts + clone.dts = data_packet.dts + clone.time_base = data_packet.time_base + clone.stream = output_data_stream + output_container.mux(clone) + + with av.open("data_copy.ts") as remuxed: + output_stream = remuxed.streams.data[0] + assert output_stream.codec_context is None + + remuxed_payloads: list[bytes] = [] + for data_packet in remuxed.demux(output_stream): + payload = bytes(data_packet) + if payload: + remuxed_payloads.append(payload) + + assert remuxed_payloads == copied_payloads + + def test_data_stream_with_codec(self) -> None: + """Test adding a data stream with a specific codec name.""" + # Test that invalid codec names raise appropriate errors + with pytest.raises(ValueError, match="Unknown data codec"): + container = av.open("data_invalid.ts", "w") + try: + container.add_data_stream("not_a_real_codec_name_12345") + finally: + container.close() + + # Test that add_data_stream with codec parameter works correctly + # We use "bin_data" which is a data codec that's always available + output_path = "data_with_codec.ts" + with av.open(output_path, "w") as container: + # Try to create a data stream with a codec + # bin_data is a simple passthrough codec for binary data + data_stream = container.add_data_stream("bin_data") + klv_stream = container.add_data_stream("klv") + + assert data_stream.type == "data" + assert klv_stream.type == "data" + # Note: codec_context may be None for descriptor-only data codecs + + test_data = [b"test1", b"test2", b"test3"] + for i, data in enumerate(test_data): + packet = av.Packet(data) + packet.pts = i + packet.stream = data_stream + container.mux(packet) + + with av.open(output_path) as newcontainer: + data_stream = newcontainer.streams.data[0] + klv_stream = newcontainer.streams.data[1] + assert data_stream.type == "data" + assert klv_stream.type == "data" + assert "bin_data" in str(data_stream) + assert "klv" in str(klv_stream) + assert data_stream.name == "bin_data" + assert klv_stream.name == "klv" + try: + os.remove(output_path) + except Exception: + pass + + def test_attachment_stream(self) -> None: + input_path = av.datasets.curated( + "pexels/time-lapse-video-of-night-sky-857195.mp4" + ) + input_ = av.open(input_path) + out1_path = "video_with_attachment.mkv" + + with av.open(out1_path, "w") as out1: + out1.add_attachment( + name="attachment.txt", mimetype="text/plain", data=b"hello\n" + ) + + in_v = input_.streams.video[0] + out_v = out1.add_stream_from_template(in_v) + + for packet in input_.demux(in_v): + if packet.size == 0: + continue + packet.stream = out_v + out1.mux(packet) + + input_.close() + + with av.open(out1_path) as c: + attachments = c.streams.attachments + assert len(attachments) == 1 + att = attachments[0] + assert att.name == "attachment.txt" + assert att.mimetype == "text/plain" + assert att.data == b"hello\n" + + out2_path = "remuxed_attachment.mkv" + with av.open(out1_path) as ic, av.open(out2_path, "w") as oc: + stream_map = {} + for s in ic.streams: + stream_map[s.index] = oc.add_stream_from_template(s) + + for packet in ic.demux(ic.streams.video): + if packet.size == 0: + continue + updated_stream = stream_map.get(packet.stream.index) + if isinstance(updated_stream, av.video.stream.VideoStream): + packet.stream = updated_stream + else: + raise ValueError("Expected a VideoStream") + oc.mux(packet) + + with av.open(out2_path) as c: + attachments = c.streams.attachments + assert len(attachments) == 1 + att = attachments[0] + assert att.name == "attachment.txt" + assert att.mimetype == "text/plain" + assert att.data == b"hello\n" + + def test_unknown_stream_type(self) -> None: + ts_data = _make_unknown_stream_ts() - # TODO: Find something in the fate suite with video, audio, and subtitles. + with av.open(io.BytesIO(ts_data), format="mpegts") as container: + assert len(container.streams) == 1 + stream = container.streams[0] + assert stream.type == "unknown" + assert type(stream) is av.stream.Stream diff --git a/tests/test_subtitles.py b/tests/test_subtitles.py index 5f7352430..ff3f58027 100644 --- a/tests/test_subtitles.py +++ b/tests/test_subtitles.py @@ -1,48 +1,226 @@ -from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle +import io +from typing import cast + import av +from av.codec.context import CodecContext +from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle, Subtitle, SubtitleSet from .common import TestCase, fate_suite -class TestSubtitle(TestCase): - - def test_movtext(self): +class TestSubtitle: + def test_movtext(self) -> None: + path = fate_suite("sub/MovText_capability_tester.mp4") - path = fate_suite('sub/MovText_capability_tester.mp4') + subs: list[AssSubtitle] = [] + with av.open(path) as container: + for packet in container.demux(): + subs.extend(cast(list[AssSubtitle], packet.decode())) - fh = av.open(path) - subs = [] - for packet in fh.demux(): - subs.extend(packet.decode()) + assert len(subs) == 3 - self.assertEqual(len(subs), 3) - self.assertIsInstance(subs[0][0], AssSubtitle) + sub = subs[0] + assert isinstance(sub, AssSubtitle) + assert sub.type == b"ass" + assert sub.text == b"" + assert sub.ass == b"0,0,Default,,0,0,0,,- Test 1.\\N- Test 2." + assert sub.dialogue == b"- Test 1.\n- Test 2." - # The format FFmpeg gives us changed at one point. - self.assertIn(subs[0][0].ass, ('Dialogue: 0,0:00:00.97,0:00:02.54,Default,- Test 1.\\N- Test 2.\r\n', - 'Dialogue: 0,0:00:00.97,0:00:02.54,Default,,0,0,0,,- Test 1.\\N- Test 2.\r\n')) + def test_subset(self) -> None: + path = fate_suite("sub/MovText_capability_tester.mp4") - def test_vobsub(self): + with av.open(path) as container: + subs = container.streams.subtitles[0] + for packet in container.demux(subs): + subset = subs.decode2(packet) + if subset is not None: + assert not isinstance(subset, Subtitle) + assert isinstance(subset, SubtitleSet) + assert subset.format == 1 + assert hasattr(subset, "pts") + assert subset.start_display_time == 0 + assert hasattr(subset, "end_display_time") - path = fate_suite('sub/vobsub.sub') + def test_vobsub(self) -> None: + path = fate_suite("sub/vobsub.sub") - fh = av.open(path) - subs = [] - for packet in fh.demux(): - subs.extend(packet.decode()) + subs: list[BitmapSubtitle] = [] + with av.open(path) as container: + for packet in container.demux(): + subs.extend(cast(list[BitmapSubtitle], packet.decode())) - self.assertEqual(len(subs), 43) + assert len(subs) == 43 - sub = subs[0][0] - self.assertIsInstance(sub, BitmapSubtitle) - self.assertEqual(sub.x, 259) - self.assertEqual(sub.y, 379) - self.assertEqual(sub.width, 200) - self.assertEqual(sub.height, 24) + sub = subs[0] + assert isinstance(sub, BitmapSubtitle) + assert sub.type == b"bitmap" + assert sub.x == 259 + assert sub.y == 379 + assert sub.width == 200 + assert sub.height == 24 + assert sub.nb_colors == 4 bms = sub.planes - self.assertEqual(len(bms), 1) - if hasattr(__builtins__, 'buffer'): - self.assertEqual(len(buffer(bms[0])), 4800) # noqa - if hasattr(__builtins__, 'memoryview'): - self.assertEqual(len(memoryview(bms[0])), 4800) # noqa + assert len(bms) == 1 + assert len(memoryview(bms[0])) == 4800 # type: ignore + + def test_subtitle_flush(self) -> None: + path = fate_suite("sub/MovText_capability_tester.mp4") + + subs: list[object] = [] + with av.open(path) as container: + stream = container.streams.subtitles[0] + for packet in container.demux(stream): + subs.extend(stream.decode(packet)) + subs.extend(stream.decode()) + + assert len(subs) == 3 + + def test_subtitle_header_read(self) -> None: + """Test reading subtitle_header from a decoded subtitle stream.""" + path = fate_suite("sub/MovText_capability_tester.mp4") + + with av.open(path) as container: + stream = container.streams.subtitles[0] + ctx = stream.codec_context + header = ctx.subtitle_header + assert header is None or isinstance(header, bytes) + + def test_subtitle_header_write(self) -> None: + """Test setting subtitle_header on encoder context.""" + ctx = CodecContext.create("mov_text", "w") + assert ctx.subtitle_header is None + + ass_header = b"[Script Info]\nScriptType: v4.00+\n" + ctx.subtitle_header = ass_header + assert ctx.subtitle_header == ass_header + + new_header = b"[Script Info]\nScriptType: v4.00\n" + ctx.subtitle_header = new_header + assert ctx.subtitle_header == new_header + + ctx.subtitle_header = None + assert ctx.subtitle_header is None + + +class TestSubtitleEncoding(TestCase): + def test_subtitle_set_create(self) -> None: + """Test creating SubtitleSet for encoding.""" + from av.subtitles.subtitle import SubtitleSet + + text = b"0,0,Default,,0,0,0,,Hello World" + subtitle = SubtitleSet.create(text=text, start=0, end=2000, pts=0) + + assert subtitle.format == 1 + assert subtitle.start_display_time == 0 + assert subtitle.end_display_time == 2000 + assert subtitle.pts == 0 + assert len(subtitle) == 1 + assert cast(AssSubtitle, subtitle[0]).ass == text + + def test_subtitle_dialogue_extended_chars(self) -> None: + """Test handling of extended UTF-8 characters in subtitle dialogue.""" + from av.subtitles.subtitle import SubtitleSet + + text = "0,0,Default,,0,0,0,,♪ Hey, hey, hey ♪".encode() + subtitle = SubtitleSet.create(text=text, start=0, end=2000, pts=0) + sub = cast(AssSubtitle, subtitle[0]) + + assert sub.dialogue == "♪ Hey, hey, hey ♪".encode() + + def test_subtitle_encode_mp4(self) -> None: + """Test encoding subtitles to MP4 container.""" + from av.subtitles.subtitle import SubtitleSet + + ass_header = b"""[Script Info] +ScriptType: v4.00+ +PlayResX: 640 +PlayResY: 480 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + + output = io.BytesIO() + with av.open(output, "w", format="mp4") as container: + # MP4 requires video for subtitles + video_stream = container.add_stream("libx264", rate=30) + video_stream.width = 640 + video_stream.height = 480 + video_stream.pix_fmt = "yuv420p" + + sub_stream = container.add_stream("mov_text") + sub_ctx = sub_stream.codec_context + sub_ctx.subtitle_header = ass_header + + container.start_encoding() + + frame = av.VideoFrame(640, 480, "yuv420p") + frame.pts = 0 + for packet in video_stream.encode(frame): + container.mux(packet) + + time_base = sub_stream.time_base + assert time_base is not None + subtitle = SubtitleSet.create( + text=b"0,0,Default,,0,0,0,,Hello World", + start=0, + end=int(2 / time_base), + pts=0, + ) + packet = sub_ctx.encode_subtitle(subtitle) + packet.stream = sub_stream + container.mux(packet) + + for packet in video_stream.encode(): + container.mux(packet) + + output.seek(0) + with av.open(output) as container: + assert len(container.streams.subtitles) == 1 + + def test_subtitle_encode_mkv_srt(self) -> None: + """Test encoding SRT subtitles to MKV container.""" + from av.subtitles.subtitle import SubtitleSet + + minimal_header = b"[Script Info]\n" + + output = io.BytesIO() + with av.open(output, "w", format="matroska") as container: + sub_stream = container.add_stream("srt") + sub_ctx = sub_stream.codec_context + sub_ctx.subtitle_header = minimal_header + + container.start_encoding() + + time_base = sub_stream.time_base + assert time_base is not None + for text, start_sec, duration_sec in [ + (b"0,0,Default,,0,0,0,,First subtitle", 0, 2), + (b"0,0,Default,,0,0,0,,Second subtitle", 2, 2), + (b"0,0,Default,,0,0,0,,Third subtitle", 4, 2), + ]: + subtitle = SubtitleSet.create( + text=text, + start=0, + end=int(duration_sec / time_base), + pts=int(start_sec / time_base), + ) + packet = sub_ctx.encode_subtitle(subtitle) + packet.stream = sub_stream + container.mux(packet) + + output.seek(0) + with av.open(output, mode="r") as input_container: + assert len(input_container.streams.subtitles) == 1 + subs: list[AssSubtitle] = [] + for packet in input_container.demux(): + subs.extend(cast(list[AssSubtitle], packet.decode())) + assert len(subs) == 3 + assert b"First subtitle" in subs[0].dialogue + assert b"Second subtitle" in subs[1].dialogue + assert b"Third subtitle" in subs[2].dialogue diff --git a/tests/test_timeout.py b/tests/test_timeout.py index a5e8bb21f..fa1d36d9a 100644 --- a/tests/test_timeout.py +++ b/tests/test_timeout.py @@ -1,16 +1,13 @@ -from http.server import BaseHTTPRequestHandler -from socketserver import TCPServer import threading import time +from http.server import BaseHTTPRequestHandler +from socketserver import TCPServer import av from .common import TestCase, fate_suite - -PORT = 8002 -CONTENT = open(fate_suite('mpeg2/mpeg2_field_encoding.ts'), 'rb').read()\ - +CONTENT = open(fate_suite("mpeg2/mpeg2_field_encoding.ts"), "rb").read() # Needs to be long enough for all host OSes to deal. TIMEOUT = 0.25 DELAY = 4 * TIMEOUT @@ -19,49 +16,65 @@ class HttpServer(TCPServer): allow_reuse_address = True - def handle_error(self, request, client_address): + def handle_error(self, request: object, client_address: object) -> None: pass class SlowRequestHandler(BaseHTTPRequestHandler): - def do_GET(self): + def do_GET(self) -> None: time.sleep(DELAY) self.send_response(200) - self.send_header('Content-Length', str(len(CONTENT))) + self.send_header("Content-Length", str(len(CONTENT))) self.end_headers() self.wfile.write(CONTENT) - def log_message(self, format, *args): + def log_message(self, format: object, *args: object) -> None: pass class TestTimeout(TestCase): - def setUp(cls): - cls._server = HttpServer(('', PORT), SlowRequestHandler) + port = 8002 + + def setUp(cls) -> None: + while True: + try: + cls._server = HttpServer(("", cls.port), SlowRequestHandler) + except OSError: + cls.port += 1 + else: + break + cls._thread = threading.Thread(target=cls._server.handle_request) cls._thread.daemon = True # Make sure the tests will exit. cls._thread.start() - def tearDown(cls): + def tearDown(cls) -> None: cls._thread.join(1) # Can't wait forever or the tests will never exit. cls._server.server_close() - def test_no_timeout(self): + def test_no_timeout(self) -> None: start = time.time() - av.open('http://localhost:%d/mpeg2_field_encoding.ts' % PORT) + av.open(f"http://localhost:{self.port}/mpeg2_field_encoding.ts") duration = time.time() - start - self.assertGreater(duration, DELAY) + assert duration > DELAY - def test_open_timeout(self): + def test_open_timeout(self) -> None: with self.assertRaises(av.ExitError): start = time.time() - av.open('http://localhost:%d/mpeg2_field_encoding.ts' % PORT, timeout=TIMEOUT) + av.open( + f"http://localhost:{self.port}/mpeg2_field_encoding.ts", timeout=TIMEOUT + ) + duration = time.time() - start - self.assertLess(duration, DELAY) + assert duration < DELAY - def test_open_timeout_2(self): + def test_open_timeout_2(self) -> None: with self.assertRaises(av.ExitError): start = time.time() - av.open('http://localhost:%d/mpeg2_field_encoding.ts' % PORT, timeout=(TIMEOUT, None)) + av.open( + f"http://localhost:{self.port}/mpeg2_field_encoding.ts", + timeout=(TIMEOUT, None), + ) + duration = time.time() - start - self.assertLess(duration, DELAY) + assert duration < DELAY diff --git a/tests/test_videoformat.py b/tests/test_videoformat.py index c46b2de34..1e2e0c0f4 100644 --- a/tests/test_videoformat.py +++ b/tests/test_videoformat.py @@ -4,87 +4,91 @@ class TestVideoFormats(TestCase): + def test_invalid_pixel_format(self): + with self.assertRaises(ValueError) as cm: + VideoFormat("__unknown_pix_fmt", 640, 480) + assert str(cm.exception) == "not a pixel format: '__unknown_pix_fmt'" - def test_rgb24_inspection(self): - fmt = VideoFormat('rgb24', 640, 480) - self.assertEqual(fmt.name, 'rgb24') - self.assertEqual(len(fmt.components), 3) - self.assertFalse(fmt.is_planar) - self.assertFalse(fmt.has_palette) - self.assertTrue(fmt.is_rgb) - self.assertEqual(fmt.chroma_width(), 640) - self.assertEqual(fmt.chroma_height(), 480) - self.assertEqual(fmt.chroma_width(1024), 1024) - self.assertEqual(fmt.chroma_height(1024), 1024) + def test_rgb24_inspection(self) -> None: + fmt = VideoFormat("rgb24", 640, 480) + assert fmt.name == "rgb24" + assert len(fmt.components) == 3 + assert not fmt.is_planar + assert not fmt.has_palette + assert fmt.is_rgb + assert fmt.chroma_width() == 640 + assert fmt.chroma_height() == 480 + assert fmt.chroma_width(1024) == 1024 + assert fmt.chroma_height(1024) == 1024 for i in range(3): comp = fmt.components[i] - self.assertEqual(comp.plane, 0) - self.assertEqual(comp.bits, 8) - self.assertFalse(comp.is_luma) - self.assertFalse(comp.is_chroma) - self.assertFalse(comp.is_alpha) - self.assertEqual(comp.width, 640) - self.assertEqual(comp.height, 480) + assert comp.plane == 0 + assert comp.bits == 8 + assert not comp.is_luma + assert not comp.is_chroma + assert not comp.is_alpha + assert comp.width == 640 + assert comp.height == 480 - def test_yuv420p_inspection(self): - fmt = VideoFormat('yuv420p', 640, 480) - self.assertEqual(fmt.name, 'yuv420p') - self.assertEqual(len(fmt.components), 3) + def test_yuv420p_inspection(self) -> None: + fmt = VideoFormat("yuv420p", 640, 480) + assert fmt.name == "yuv420p" + assert len(fmt.components) == 3 self._test_yuv420(fmt) - def _test_yuv420(self, fmt): - self.assertTrue(fmt.is_planar) - self.assertFalse(fmt.has_palette) - self.assertFalse(fmt.is_rgb) - self.assertEqual(fmt.chroma_width(), 320) - self.assertEqual(fmt.chroma_height(), 240) - self.assertEqual(fmt.chroma_width(1024), 512) - self.assertEqual(fmt.chroma_height(1024), 512) + def _test_yuv420(self, fmt: VideoFormat) -> None: + assert fmt.is_planar + assert not fmt.has_palette + assert not fmt.is_rgb + assert fmt.chroma_width() == 320 + assert fmt.chroma_height() == 240 + assert fmt.chroma_width(1024) == 512 + assert fmt.chroma_height(1024) == 512 for i, comp in enumerate(fmt.components): comp = fmt.components[i] - self.assertEqual(comp.plane, i) - self.assertEqual(comp.bits, 8) - self.assertFalse(fmt.components[0].is_chroma) - self.assertTrue(fmt.components[1].is_chroma) - self.assertTrue(fmt.components[2].is_chroma) - self.assertTrue(fmt.components[0].is_luma) - self.assertFalse(fmt.components[1].is_luma) - self.assertFalse(fmt.components[2].is_luma) - self.assertFalse(fmt.components[0].is_alpha) - self.assertFalse(fmt.components[1].is_alpha) - self.assertFalse(fmt.components[2].is_alpha) - self.assertEqual(fmt.components[0].width, 640) - self.assertEqual(fmt.components[1].width, 320) - self.assertEqual(fmt.components[2].width, 320) + assert comp.plane == i + assert comp.bits == 8 + assert not fmt.components[0].is_chroma + assert fmt.components[1].is_chroma + assert fmt.components[2].is_chroma + assert fmt.components[0].is_luma + assert not fmt.components[1].is_luma + assert not fmt.components[2].is_luma + assert not fmt.components[0].is_alpha + assert not fmt.components[1].is_alpha + assert not fmt.components[2].is_alpha + assert fmt.components[0].width == 640 + assert fmt.components[1].width == 320 + assert fmt.components[2].width == 320 - def test_yuva420p_inspection(self): - fmt = VideoFormat('yuva420p', 640, 480) - self.assertEqual(len(fmt.components), 4) + def test_yuva420p_inspection(self) -> None: + fmt = VideoFormat("yuva420p", 640, 480) + assert len(fmt.components) == 4 self._test_yuv420(fmt) - self.assertFalse(fmt.components[3].is_chroma) - self.assertEqual(fmt.components[3].width, 640) + assert not fmt.components[3].is_chroma + assert fmt.components[3].width == 640 - def test_gray16be_inspection(self): - fmt = VideoFormat('gray16be', 640, 480) - self.assertEqual(fmt.name, 'gray16be') - self.assertEqual(len(fmt.components), 1) - self.assertFalse(fmt.is_planar) - self.assertFalse(fmt.has_palette) - self.assertFalse(fmt.is_rgb) - self.assertEqual(fmt.chroma_width(), 640) - self.assertEqual(fmt.chroma_height(), 480) - self.assertEqual(fmt.chroma_width(1024), 1024) - self.assertEqual(fmt.chroma_height(1024), 1024) + def test_gray16be_inspection(self) -> None: + fmt = VideoFormat("gray16be", 640, 480) + assert fmt.name == "gray16be" + assert len(fmt.components) == 1 + assert not fmt.is_planar + assert not fmt.has_palette + assert not fmt.is_rgb + assert fmt.chroma_width() == 640 + assert fmt.chroma_height() == 480 + assert fmt.chroma_width(1024) == 1024 + assert fmt.chroma_height(1024) == 1024 comp = fmt.components[0] - self.assertEqual(comp.plane, 0) - self.assertEqual(comp.bits, 16) - self.assertTrue(comp.is_luma) - self.assertFalse(comp.is_chroma) - self.assertEqual(comp.width, 640) - self.assertEqual(comp.height, 480) - self.assertFalse(comp.is_alpha) + assert comp.plane == 0 + assert comp.bits == 16 + assert comp.is_luma + assert not comp.is_chroma + assert comp.width == 640 + assert comp.height == 480 + assert not comp.is_alpha - def test_pal8_inspection(self): - fmt = VideoFormat('pal8', 640, 480) - self.assertEqual(len(fmt.components), 1) - self.assertTrue(fmt.has_palette) + def test_pal8_inspection(self) -> None: + fmt = VideoFormat("pal8", 640, 480) + assert len(fmt.components) == 1 + assert fmt.has_palette diff --git a/tests/test_videoframe.py b/tests/test_videoframe.py index a016f81b4..acd3f53ab 100644 --- a/tests/test_videoframe.py +++ b/tests/test_videoframe.py @@ -1,335 +1,1381 @@ -from unittest import SkipTest -import warnings +from fractions import Fraction import numpy +import pytest +import av from av import VideoFrame -from av.deprecation import AttributeRenamedWarning - -from .common import Image, TestCase, fate_png - - -class TestVideoFrameConstructors(TestCase): - - def test_null_constructor(self): - frame = VideoFrame() - self.assertEqual(frame.width, 0) - self.assertEqual(frame.height, 0) - self.assertEqual(frame.format.name, 'yuv420p') - - def test_manual_yuv_constructor(self): - frame = VideoFrame(640, 480, 'yuv420p') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'yuv420p') - - def test_manual_rgb_constructor(self): - frame = VideoFrame(640, 480, 'rgb24') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'rgb24') - - -class TestVideoFramePlanes(TestCase): - - def test_null_planes(self): - frame = VideoFrame() # yuv420p - self.assertEqual(len(frame.planes), 0) - - def test_yuv420p_planes(self): - frame = VideoFrame(640, 480, 'yuv420p') - self.assertEqual(len(frame.planes), 3) - self.assertEqual(frame.planes[0].width, 640) - self.assertEqual(frame.planes[0].height, 480) - self.assertEqual(frame.planes[0].line_size, 640) - self.assertEqual(frame.planes[0].buffer_size, 640 * 480) - for i in range(1, 3): - self.assertEqual(frame.planes[i].width, 320) - self.assertEqual(frame.planes[i].height, 240) - self.assertEqual(frame.planes[i].line_size, 320) - self.assertEqual(frame.planes[i].buffer_size, 320 * 240) - - def test_yuv420p_planes_align(self): - # If we request 8-byte alignment for a width which is not a multiple of 8, - # the line sizes are larger than the plane width. - frame = VideoFrame(318, 238, 'yuv420p') - self.assertEqual(len(frame.planes), 3) - self.assertEqual(frame.planes[0].width, 318) - self.assertEqual(frame.planes[0].height, 238) - self.assertEqual(frame.planes[0].line_size, 320) - self.assertEqual(frame.planes[0].buffer_size, 320 * 238) - for i in range(1, 3): - self.assertEqual(frame.planes[i].width, 159) - self.assertEqual(frame.planes[i].height, 119) - self.assertEqual(frame.planes[i].line_size, 160) - self.assertEqual(frame.planes[i].buffer_size, 160 * 119) - - def test_rgb24_planes(self): - frame = VideoFrame(640, 480, 'rgb24') - self.assertEqual(len(frame.planes), 1) - self.assertEqual(frame.planes[0].width, 640) - self.assertEqual(frame.planes[0].height, 480) - self.assertEqual(frame.planes[0].line_size, 640 * 3) - self.assertEqual(frame.planes[0].buffer_size, 640 * 480 * 3) - - -class TestVideoFrameBuffers(TestCase): - - def test_buffer(self): - if not hasattr(__builtins__, 'buffer'): - raise SkipTest() - - frame = VideoFrame(640, 480, 'rgb24') - frame.planes[0].update(b'01234' + (b'x' * (640 * 480 * 3 - 5))) - buf = buffer(frame.planes[0]) # noqa - self.assertEqual(buf[1], b'1') - self.assertEqual(buf[:7], b'01234xx') - - def test_memoryview_read(self): - if not hasattr(__builtins__, 'memoryview'): - raise SkipTest() - - frame = VideoFrame(640, 480, 'rgb24') - frame.planes[0].update(b'01234' + (b'x' * (640 * 480 * 3 - 5))) - mem = memoryview(frame.planes[0]) # noqa - self.assertEqual(mem.ndim, 1) - self.assertEqual(mem.shape, (640 * 480 * 3, )) - self.assertFalse(mem.readonly) - self.assertEqual(mem[1], 49) - self.assertEqual(mem[:7], b'01234xx') - mem[1] = 46 - self.assertEqual(mem[:7], b'0.234xx') - - -class TestVideoFrameImage(TestCase): - - def setUp(self): - if not Image: - raise SkipTest() - - def test_roundtrip(self): - image = Image.open(fate_png()) - frame = VideoFrame.from_image(image) - img = frame.to_image() - img.save(self.sandboxed('roundtrip-high.jpg')) - self.assertImagesAlmostEqual(image, img) - - def test_to_image_rgb24(self): - sizes = [ - (318, 238), - (320, 240), - (500, 500), +from av.video.frame import supported_np_pix_fmts +from av.video.reformatter import Colorspace, Interpolation + +from .common import assertNdarraysEqual, fate_png, fate_suite + + +def assertPixelValue16(plane, expected, byteorder: str) -> None: + view = memoryview(plane) + if byteorder == "big": + assert view[0] == (expected >> 8 & 0xFF) + assert view[1] == expected & 0xFF + else: + assert view[0] == expected & 0xFF + assert view[1] == (expected >> 8 & 0xFF) + + +def test_frame_duration_matches_packet() -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + packet_durations = [ + (p.pts, p.duration) for p in container.demux() if p.pts is not None ] - for width, height in sizes: - frame = VideoFrame(width, height, format='rgb24') - - # fill video frame data - for plane in frame.planes: - ba = bytearray(plane.buffer_size) - pos = 0 - for row in range(height): - for i in range(plane.line_size): - ba[pos] = i % 256 - pos += 1 - plane.update(ba) - - # construct expected image data - expected = bytearray(height * width * 3) - pos = 0 - for row in range(height): - for i in range(width * 3): - expected[pos] = i % 256 - pos += 1 - - img = frame.to_image() - self.assertEqual(img.size, (width, height)) - self.assertEqual(img.tobytes(), expected) - - -class TestVideoFrameNdarray(TestCase): - - def test_basic_to_ndarray(self): - frame = VideoFrame(640, 480, 'rgb24') + packet_durations.sort(key=lambda x: x[0]) + + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + frame_durations = [ + (f.pts, f.duration) for f in container.decode(video=0) if f.pts is not None + ] + frame_durations.sort(key=lambda x: x[0]) + + assert len(packet_durations) == len(frame_durations) + assert all(pd[1] == fd[1] for pd, fd in zip(packet_durations, frame_durations)) + + +def test_invalid_pixel_format() -> None: + with pytest.raises(ValueError, match="not a pixel format: '__unknown_pix_fmt'"): + VideoFrame(640, 480, "__unknown_pix_fmt") + + +def test_null_constructor() -> None: + frame = VideoFrame() + assert frame.width == 0 + assert frame.height == 0 + assert frame.format.name == "yuv420p" + + +def test_manual_yuv_constructor() -> None: + frame = VideoFrame(640, 480, "yuv420p") + assert frame.width == 640 + assert frame.height == 480 + assert frame.format.name == "yuv420p" + + +def test_manual_rgb_constructor() -> None: + frame = VideoFrame(640, 480, "rgb24") + assert frame.width == 640 + assert frame.height == 480 + assert frame.format.name == "rgb24" + + +def test_null_planes() -> None: + frame = VideoFrame() # yuv420p + assert len(frame.planes) == 0 + + +def test_yuv420p_planes() -> None: + frame = VideoFrame(640, 480, "yuv420p") + assert len(frame.planes) == 3 + assert frame.planes[0].width == 640 + assert frame.planes[0].height == 480 + assert frame.planes[0].line_size == 640 + assert frame.planes[0].buffer_size == 640 * 480 + for i in range(1, 3): + assert frame.planes[i].width == 320 + assert frame.planes[i].height == 240 + assert frame.planes[i].line_size == 320 + assert frame.planes[i].buffer_size == 320 * 240 + + +def test_yuv420p_planes_align() -> None: + # If we request 8-byte alignment for a width which is not a multiple of 8, + # the line sizes are larger than the plane width. + frame = VideoFrame(318, 238, "yuv420p") + assert len(frame.planes) == 3 + assert frame.planes[0].width == 318 + assert frame.planes[0].height == 238 + assert frame.planes[0].line_size == 320 + assert frame.planes[0].buffer_size == 320 * 238 + for i in range(1, 3): + assert frame.planes[i].width == 159 + assert frame.planes[i].height == 119 + assert frame.planes[i].line_size == 160 + assert frame.planes[i].buffer_size == 160 * 119 + + +def test_rgb24_planes() -> None: + frame = VideoFrame(640, 480, "rgb24") + assert len(frame.planes) == 1 + assert frame.planes[0].width == 640 + assert frame.planes[0].height == 480 + assert frame.planes[0].line_size == 640 * 3 + assert frame.planes[0].buffer_size == 640 * 480 * 3 + + +def test_memoryview_read() -> None: + frame = VideoFrame(640, 480, "rgb24") + frame.planes[0].update(b"01234" + (b"x" * (640 * 480 * 3 - 5))) + mem = memoryview(frame.planes[0]) + assert mem.ndim == 1 + assert mem.shape == (640 * 480 * 3,) + assert not mem.readonly + assert mem[1] == 49 + assert mem[:7] == b"01234xx" + mem[1] = 46 + assert mem[:7] == b"0.234xx" + + +def test_opaque() -> None: + frame = VideoFrame(640, 480, "rgb24") + frame.opaque = 3 + assert frame.opaque == 3 + frame.opaque = "a" + assert frame.opaque == "a" + + greeting = "Hello World!" + frame.opaque = greeting + assert frame.opaque is greeting + + frame.opaque = None + assert frame.opaque is None + + +def test_interpolation() -> None: + container = av.open(fate_png()) + for _ in container.decode(video=0): + frame = _ + break + + assert frame.width == 330 and frame.height == 330 + + img = frame.reformat(width=200, height=100, interpolation=Interpolation.BICUBIC) + assert img.width == 200 and img.height == 100 + + img = frame.reformat(width=200, height=100, interpolation="BICUBIC") + assert img.width == 200 and img.height == 100 + + img = frame.reformat( + width=200, height=100, interpolation=int(Interpolation.BICUBIC) + ) + assert img.width == 200 and img.height == 100 + + # Option flags can be combined with the scaling algorithm. + combined = Interpolation.BILINEAR | Interpolation.ACCURATE_RND + assert isinstance(combined, Interpolation) + assert combined & Interpolation.ACCURATE_RND + img = frame.reformat(width=200, height=100, interpolation=combined) + assert img.width == 200 and img.height == 100 + + +def test_basic_to_ndarray() -> None: + array = VideoFrame(640, 480, "rgb24").to_ndarray() + assert array.shape == (480, 640, 3) + + +def _vflip(frame: VideoFrame) -> VideoFrame: + """Vertically flip a frame, which yields a bottom-up frame with a negative + ``line_size`` (the same layout DirectShow produces, see GH-2213).""" + graph = av.filter.Graph() + src = graph.add_buffer( + template=None, + width=frame.width, + height=frame.height, + format=frame.format, + time_base=Fraction(1, 1000), + ) + vflip = graph.add("vflip") + sink = graph.add("buffersink") + src.link_to(vflip) + vflip.link_to(sink) + graph.configure() + graph.push(frame) + out = graph.pull() + assert isinstance(out, VideoFrame) + return out + + +@pytest.mark.parametrize("format", ["rgb24", "bgr24", "gray"]) +def test_negative_linesize_to_ndarray(format: str) -> None: + # Bottom-up packed frames have a negative line_size; to_ndarray() must read + # them without crashing (GH-2213) and in the correct top-down order. + height, width = 6, 4 + if format == "gray": + array = numpy.arange(height * width, dtype=numpy.uint8).reshape(height, width) + else: + array = numpy.zeros((height, width, 3), dtype=numpy.uint8) + for row in range(height): + array[row, :, :] = row * 10 + + frame = _vflip(VideoFrame.from_ndarray(array, format=format)) + assert frame.planes[0].line_size < 0 + + result = frame.to_ndarray(format=format) + assertNdarraysEqual(result, array[::-1]) + # Fully materializing the array used to segfault on a bottom-up frame. + assert result.copy().sum() == int(array.sum()) + + +def test_ndarray_gray() -> None: + array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) + for format in ("gray", "gray8"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gray_align() -> None: + array = numpy.random.randint(0, 256, size=(238, 318), dtype=numpy.uint8) + for format in ("gray", "gray8"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "gray" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gray9be() -> None: + array = numpy.random.randint(0, 512, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray9be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray9be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "big") + + +def test_ndarray_gray9le() -> None: + array = numpy.random.randint(0, 512, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray9le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray9le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "little") + + +def test_ndarray_gray10be() -> None: + array = numpy.random.randint(0, 1024, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray10be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray10be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "big") + + +def test_ndarray_gray10le() -> None: + array = numpy.random.randint(0, 1024, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray10le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray10le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "little") + + +def test_ndarray_gray12be() -> None: + array = numpy.random.randint(0, 4096, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray12be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray12be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "big") + + +def test_ndarray_gray12le() -> None: + array = numpy.random.randint(0, 4096, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray12le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray12le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "little") + + +def test_ndarray_gray14be() -> None: + array = numpy.random.randint(0, 16384, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray14be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray14be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "big") + + +def test_ndarray_gray14le() -> None: + array = numpy.random.randint(0, 16384, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray14le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray14le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "little") + + +def test_ndarray_gray16be() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray16be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray16be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "big") + + +def test_ndarray_gray16le() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="gray16le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gray16le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining value of first pixel + assertPixelValue16(frame.planes[0], array[0][0], "little") + + +def test_ndarray_grayf32() -> None: + array = numpy.random.random_sample(size=(480, 640)).astype(numpy.float32) + for format in ("grayf32be", "grayf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_grayf32_align() -> None: + array = numpy.random.random_sample(size=(238, 318)).astype(numpy.float32) + for format in ("grayf32be", "grayf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgb() -> None: + array = numpy.random.randint(0, 256, size=(480, 640, 3), dtype=numpy.uint8) + for format in ("rgb24", "bgr24"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgb_align() -> None: + array = numpy.random.randint(0, 256, size=(238, 318, 3), dtype=numpy.uint8) + for format in ("rgb24", "bgr24"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgbf32() -> None: + array = numpy.random.random_sample(size=(480, 640, 3)).astype(numpy.float32) + for format in ("rgbf32be", "rgbf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgba() -> None: + array = numpy.random.randint(0, 256, size=(480, 640, 4), dtype=numpy.uint8) + for format in ("argb", "rgba", "abgr", "bgra"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgba_align() -> None: + array = numpy.random.randint(0, 256, size=(238, 318, 4), dtype=numpy.uint8) + for format in ("argb", "rgba", "abgr", "bgra"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_bayer8() -> None: + array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) + for format in ("bayer_bggr8", "bayer_gbrg8", "bayer_grbg8", "bayer_rggb8"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_bayer16() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640), dtype=numpy.uint16) + for format in ( + "bayer_bggr16be", + "bayer_bggr16le", + "bayer_gbrg16be", + "bayer_gbrg16le", + "bayer_grbg16be", + "bayer_grbg16le", + "bayer_rggb16be", + "bayer_rggb16le", + ): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap() -> None: + array = numpy.random.randint(0, 256, size=(480, 640, 4), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="gbrap") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gbrap" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap_align() -> None: + array = numpy.random.randint(0, 256, size=(238, 318, 4), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="gbrap") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "gbrap" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap10() -> None: + array = numpy.random.randint(0, 1024, size=(480, 640, 4), dtype=numpy.uint16) + for format in ("gbrap10be", "gbrap10le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap10_align() -> None: + array = numpy.random.randint(0, 1024, size=(238, 318, 4), dtype=numpy.uint16) + for format in ("gbrap10be", "gbrap10le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap12() -> None: + array = numpy.random.randint(0, 4096, size=(480, 640, 4), dtype=numpy.uint16) + for format in ("gbrap12be", "gbrap12le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap12_align() -> None: + array = numpy.random.randint(0, 4096, size=(238, 318, 4), dtype=numpy.uint16) + for format in ("gbrap12be", "gbrap12le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap14() -> None: + array = numpy.random.randint(0, 16384, size=(480, 640, 4), dtype=numpy.uint16) + for format in ("gbrap14be", "gbrap14le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap14_align() -> None: + array = numpy.random.randint(0, 16384, size=(238, 318, 4), dtype=numpy.uint16) + for format in ("gbrap14be", "gbrap14le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap16() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 4), dtype=numpy.uint16) + for format in ("gbrap16be", "gbrap16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrap16_align() -> None: + array = numpy.random.randint(0, 65536, size=(238, 318, 4), dtype=numpy.uint16) + for format in ("gbrap16be", "gbrap16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrapf32() -> None: + array = numpy.random.random_sample(size=(480, 640, 4)).astype(numpy.float32) + for format in ("gbrapf32be", "gbrapf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrapf32_align() -> None: + array = numpy.random.random_sample(size=(238, 318, 4)).astype(numpy.float32) + for format in ("gbrapf32be", "gbrapf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp() -> None: + array = numpy.random.randint(0, 256, size=(480, 640, 3), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="gbrp") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "gbrp" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp_align() -> None: + array = numpy.random.randint(0, 256, size=(238, 318, 3), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="gbrp") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "gbrp" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp9() -> None: + array = numpy.random.randint(0, 512, size=(480, 640, 3), dtype=numpy.uint16) + for format in ("gbrp9be", "gbrp9le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp9_align() -> None: + array = numpy.random.randint(0, 512, size=(238, 318, 3), dtype=numpy.uint16) + for format in ("gbrp9be", "gbrp9le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp10() -> None: + array = numpy.random.randint(0, 1024, size=(480, 640, 3), dtype=numpy.uint16) + for format in ("gbrp10be", "gbrp10le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp10_align() -> None: + array = numpy.random.randint(0, 1024, size=(238, 318, 3), dtype=numpy.uint16) + for format in ("gbrp10be", "gbrp10le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp12() -> None: + array = numpy.random.randint(0, 4096, size=(480, 640, 3), dtype=numpy.uint16) + for format in ("gbrp12be", "gbrp12le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp12_align() -> None: + array = numpy.random.randint(0, 4096, size=(238, 318, 3), dtype=numpy.uint16) + for format in ("gbrp12be", "gbrp12le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp14() -> None: + array = numpy.random.randint(0, 16384, size=(480, 640, 3), dtype=numpy.uint16) + for format in ("gbrp14be", "gbrp14le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp14_align() -> None: + array = numpy.random.randint(0, 16384, size=(238, 318, 3), dtype=numpy.uint16) + for format in ("gbrp14be", "gbrp14le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp16() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 3), dtype=numpy.uint16) + for format in ("gbrp16be", "gbrp16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrp16_align() -> None: + array = numpy.random.randint(0, 65536, size=(238, 318, 3), dtype=numpy.uint16) + for format in ("gbrp16be", "gbrp16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert format in supported_np_pix_fmts + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrpf32() -> None: + array = numpy.random.random_sample(size=(480, 640, 3)).astype(numpy.float32) + for format in ("gbrpf32be", "gbrpf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_gbrpf32_align() -> None: + array = numpy.random.random_sample(size=(238, 318, 3)).astype(numpy.float32) + for format in ["gbrpf32be", "gbrpf32le"]: + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv420p() -> None: + array = numpy.random.randint(0, 256, size=(720, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuv420p") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuv420p" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv420p10le() -> None: + array = numpy.random.randint(0, 1024, size=(720, 640), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="yuv420p10le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuv420p10le" + assert "yuv420p10le" in supported_np_pix_fmts + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv420p10le_align() -> None: + array = numpy.random.randint(0, 1024, size=(357, 318), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="yuv420p10le") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "yuv420p10le" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv420p10le_zero_size() -> None: + # A frame with zero width and/or height has no allocated planes; conversions + # should degrade gracefully instead of raising IndexError. + for w, h in ((0, 0), (0, 480), (640, 0)): + frame = VideoFrame(w, h, "yuv420p10le") array = frame.to_ndarray() - self.assertEqual(array.shape, (480, 640, 3)) - - def test_basic_to_nd_array(self): - frame = VideoFrame(640, 480, 'rgb24') - with warnings.catch_warnings(record=True) as recorded: - array = frame.to_nd_array() - self.assertEqual(array.shape, (480, 640, 3)) - - # check deprecation warning - self.assertEqual(len(recorded), 1) - self.assertEqual(recorded[0].category, AttributeRenamedWarning) - self.assertEqual( - str(recorded[0].message), - 'VideoFrame.to_nd_array is deprecated; please use VideoFrame.to_ndarray.') - - def test_ndarray_gray(self): - array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) - for format in ['gray', 'gray8']: - frame = VideoFrame.from_ndarray(array, format=format) - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'gray') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_gray_align(self): - array = numpy.random.randint(0, 256, size=(238, 318), dtype=numpy.uint8) - for format in ['gray', 'gray8']: - frame = VideoFrame.from_ndarray(array, format=format) - self.assertEqual(frame.width, 318) - self.assertEqual(frame.height, 238) - self.assertEqual(frame.format.name, 'gray') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_rgb(self): - array = numpy.random.randint(0, 256, size=(480, 640, 3), dtype=numpy.uint8) - for format in ['rgb24', 'bgr24']: - frame = VideoFrame.from_ndarray(array, format=format) - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, format) - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_rgb_align(self): - array = numpy.random.randint(0, 256, size=(238, 318, 3), dtype=numpy.uint8) - for format in ['rgb24', 'bgr24']: - frame = VideoFrame.from_ndarray(array, format=format) - self.assertEqual(frame.width, 318) - self.assertEqual(frame.height, 238) - self.assertEqual(frame.format.name, format) - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_rgba(self): - array = numpy.random.randint(0, 256, size=(480, 640, 4), dtype=numpy.uint8) - for format in ['argb', 'rgba', 'abgr', 'bgra']: - frame = VideoFrame.from_ndarray(array, format=format) - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, format) - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_rgba_align(self): - array = numpy.random.randint(0, 256, size=(238, 318, 4), dtype=numpy.uint8) - for format in ['argb', 'rgba', 'abgr', 'bgra']: - frame = VideoFrame.from_ndarray(array, format=format) - self.assertEqual(frame.width, 318) - self.assertEqual(frame.height, 238) - self.assertEqual(frame.format.name, format) - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_yuv420p(self): - array = numpy.random.randint(0, 256, size=(720, 640), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='yuv420p') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'yuv420p') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_yuv420p_align(self): + assert array.dtype == numpy.uint16 + assert array.size == 0 + + empty = numpy.empty((0, 0), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(empty, format="yuv420p10le") + assert frame.width == 0 and frame.height == 0 + assert frame.format.name == "yuv420p10le" + assertNdarraysEqual(frame.to_ndarray(), empty) + + +def test_ndarray_yuv422p() -> None: + array = numpy.random.randint(0, 256, size=(960, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuv422p") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuv422p" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv420p_align() -> None: + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuv420p") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "yuv420p" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuvj420p() -> None: + array = numpy.random.randint(0, 256, size=(720, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuvj420p") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuvj420p" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuyv422() -> None: + array = numpy.random.randint(0, 256, size=(480, 640, 2), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuyv422") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuyv422" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv444p() -> None: + array = numpy.random.randint(0, 256, size=(3, 480, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuv444p") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuv444p" + assertNdarraysEqual(frame.to_ndarray(), array) + + array = numpy.random.randint(0, 256, size=(3, 480, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, channel_last=False, format="yuv444p") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuv444p" + assertNdarraysEqual(frame.to_ndarray(channel_last=False), array) + assert array.shape != frame.to_ndarray(channel_last=True).shape + assert ( + frame.to_ndarray(channel_last=False).shape + != frame.to_ndarray(channel_last=True).shape + ) + + +def test_ndarray_yuvj444p() -> None: + array = numpy.random.randint(0, 256, size=(3, 480, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuvj444p") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "yuvj444p" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv444p16() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 3), dtype=numpy.uint16) + for format in ("yuv444p16be", "yuv444p16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuv422p10le() -> None: + array = numpy.random.randint(0, 65536, size=(3, 480, 640), dtype=numpy.uint16) + for format in ("yuv422p10le",): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assert format in supported_np_pix_fmts + + +def test_ndarray_yuv444p16_align() -> None: + array = numpy.random.randint(0, 65536, size=(238, 318, 3), dtype=numpy.uint16) + for format in ("yuv444p16be", "yuv444p16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuva444p16() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 4), dtype=numpy.uint16) + for format in ("yuva444p16be", "yuva444p16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuva444p16_align() -> None: + array = numpy.random.randint(0, 65536, size=(238, 318, 4), dtype=numpy.uint16) + for format in ("yuva444p16be", "yuva444p16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_yuyv422_align() -> None: + array = numpy.random.randint(0, 256, size=(238, 318, 2), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="yuyv422") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "yuyv422" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgb48be() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 3), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="rgb48be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "rgb48be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining red value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "big") + + +def test_ndarray_bgr48be() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 3), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="bgr48be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "bgr48be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining blue value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "big") + + +def test_ndarray_rgb48le() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 3), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="rgb48le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "rgb48le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining red value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "little") + + +def test_ndarray_bgr48le() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 3), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="bgr48le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "bgr48le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining blue value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "little") + + +def test_ndarray_rgb48le_align() -> None: + array = numpy.random.randint(0, 65536, size=(238, 318, 3), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="rgb48le") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "rgb48le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining red value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "little") + + +def test_ndarray_bgr48le_align() -> None: + array = numpy.random.randint(0, 65536, size=(238, 318, 3), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="bgr48le") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "bgr48le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining blue value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "little") + + +def test_ndarray_rgba64be() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 4), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="rgba64be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "rgba64be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining red value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "big") + + +def test_ndarray_bgra64be() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 4), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="bgra64be") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "bgra64be" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining blue value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "big") + + +def test_ndarray_rgba64le() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 4), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="rgba64le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "rgba64le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining red value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "little") + + +def test_ndarray_bgra64le() -> None: + array = numpy.random.randint(0, 65536, size=(480, 640, 4), dtype=numpy.uint16) + frame = VideoFrame.from_ndarray(array, format="bgra64le") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "bgra64le" + assertNdarraysEqual(frame.to_ndarray(), array) + + # check endianness by examining blue value of first pixel + assertPixelValue16(frame.planes[0], array[0][0][0], "little") + + +def test_ndarray_rgbaf16() -> None: + array = numpy.random.random_sample(size=(480, 640, 4)).astype(numpy.float16) + for format in ("rgbaf16be", "rgbaf16le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgbaf32() -> None: + array = numpy.random.random_sample(size=(480, 640, 4)).astype(numpy.float32) + for format in ("rgbaf32be", "rgbaf32le"): + frame = VideoFrame.from_ndarray(array, format=format) + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == format + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_rgb8() -> None: + array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="rgb8") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "rgb8" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_bgr8() -> None: + array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="bgr8") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "bgr8" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_pal8(): + array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) + palette = numpy.random.randint(0, 256, size=(256, 4), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray((array, palette), format="pal8") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "pal8" + assert frame.format.name in supported_np_pix_fmts + returned = frame.to_ndarray() + assert type(returned) is tuple and len(returned) == 2 + assertNdarraysEqual(returned[0], array) + assertNdarraysEqual(returned[1], palette) + + +def test_ndarray_nv12() -> None: + array = numpy.random.randint(0, 256, size=(720, 640), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="nv12") + assert frame.width == 640 and frame.height == 480 + assert frame.format.name == "nv12" + assert frame.format.name in supported_np_pix_fmts + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_ndarray_nv12_align() -> None: + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_ndarray(array, format="nv12") + assert frame.width == 318 and frame.height == 238 + assert frame.format.name == "nv12" + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_gray() -> None: + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "gray") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + array = array[:, :300] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "gray") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_gray8() -> None: + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "gray8") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + array = array[:, :300] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "gray8") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_rgb8() -> None: + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "rgb8") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + array = array[:, :300] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "rgb8") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_bgr8() -> None: + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "bgr8") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + array = array[:, :300] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "bgr8") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_rgb24() -> None: + array = numpy.random.randint(0, 256, size=(357, 318, 3), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "rgb24") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318, 3), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318, 3), dtype=numpy.uint8) + array = array[:, :300, :] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "rgb24") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_rgba() -> None: + array = numpy.random.randint(0, 256, size=(357, 318, 4), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "rgba") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318, 4), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318, 4), dtype=numpy.uint8) + array = array[:, :300, :] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "rgba") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_bayer8() -> None: + for format in ("bayer_rggb8", "bayer_bggr8", "bayer_grbg8", "bayer_gbrg8"): + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, format) + assertNdarraysEqual(frame.to_ndarray(), array) + + array[...] = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) + assertNdarraysEqual(frame.to_ndarray(), array) + array = numpy.random.randint(0, 256, size=(357, 318), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='yuv420p') - self.assertEqual(frame.width, 318) - self.assertEqual(frame.height, 238) - self.assertEqual(frame.format.name, 'yuv420p') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_yuvj420p(self): - array = numpy.random.randint(0, 256, size=(720, 640), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='yuvj420p') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'yuvj420p') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_yuyv422(self): - array = numpy.random.randint(0, 256, size=(480, 640, 2), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='yuyv422') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'yuyv422') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_yuyv422_align(self): - array = numpy.random.randint(0, 256, size=(238, 318, 2), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='yuyv422') - self.assertEqual(frame.width, 318) - self.assertEqual(frame.height, 238) - self.assertEqual(frame.format.name, 'yuyv422') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_rgb8(self): - array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='rgb8') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'rgb8') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_bgr8(self): - array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray(array, format='bgr8') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'bgr8') - self.assertTrue((frame.to_ndarray() == array).all()) - - def test_ndarray_pal8(self): - array = numpy.random.randint(0, 256, size=(480, 640), dtype=numpy.uint8) - palette = numpy.random.randint(0, 256, size=(256, 4), dtype=numpy.uint8) - frame = VideoFrame.from_ndarray((array, palette), format='pal8') - self.assertEqual(frame.width, 640) - self.assertEqual(frame.height, 480) - self.assertEqual(frame.format.name, 'pal8') - returned = frame.to_ndarray() - self.assertTrue((type(returned) is tuple) and len(returned) == 2) - self.assertTrue((returned[0] == array).all()) - self.assertTrue((returned[1] == palette).all()) - - -class TestVideoFrameTiming(TestCase): - - def test_reformat_pts(self): - frame = VideoFrame(640, 480, 'rgb24') - frame.pts = 123 - frame.time_base = '456/1' # Just to be different. - frame = frame.reformat(320, 240) - self.assertEqual(frame.pts, 123) - self.assertEqual(frame.time_base, 456) - - -class TestVideoFrameReformat(TestCase): - - def test_reformat_identity(self): - frame1 = VideoFrame(640, 480, 'rgb24') - frame2 = frame1.reformat(640, 480, 'rgb24') - self.assertIs(frame1, frame2) - - def test_reformat_colourspace(self): - - # This is allowed. - frame = VideoFrame(640, 480, 'rgb24') - frame.reformat(src_colorspace=None, dst_colorspace='smpte240') - - # I thought this was not allowed, but it seems to be. - frame = VideoFrame(640, 480, 'yuv420p') - frame.reformat(src_colorspace=None, dst_colorspace='smpte240') - - def test_reformat_pixel_format_align(self): - height = 480 - for width in range(2, 258, 2): - frame_yuv = VideoFrame(width, height, 'yuv420p') - for plane in frame_yuv.planes: - plane.update(b'\xff' * plane.buffer_size) - - expected_rgb = numpy.zeros(shape=(height, width, 3), dtype=numpy.uint8) - expected_rgb[:, :, 0] = 255 - expected_rgb[:, :, 1] = 124 - expected_rgb[:, :, 2] = 255 - - frame_rgb = frame_yuv.reformat(format='rgb24') - array_rgb = frame_rgb.to_ndarray() - self.assertEqual(array_rgb.shape, (height, width, 3)) - self.assertTrue((array_rgb == expected_rgb).all()) + array = array[:, :300] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, format) + assertNdarraysEqual(frame.to_ndarray(), array) + + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_yuv420p() -> None: + array = numpy.random.randint(0, 256, size=(512 * 6 // 4, 256), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "yuv420p") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array where there are some padding bytes + # note that the uv rows have half the padding in the middle of a row, and the + # other half at the end + height = 512 + stride = 256 + width = 200 + array = numpy.random.randint( + 0, 256, size=(height * 6 // 4, stride), dtype=numpy.uint8 + ) + uv_width = width // 2 + uv_stride = stride // 2 + + # compare carefully, avoiding all the padding bytes which to_ndarray strips out + frame = VideoFrame.from_numpy_buffer(array, "yuv420p", width=width) + frame_array = frame.to_ndarray() + assertNdarraysEqual(frame_array[:height, :width], array[:height, :width]) + assertNdarraysEqual(frame_array[height:, :uv_width], array[height:, :uv_width]) + assertNdarraysEqual( + frame_array[height:, uv_width:], + array[height:, uv_stride : uv_stride + uv_width], + ) + + # overwrite the array, and check the shared frame buffer changed too! + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + frame_array = frame.to_ndarray() + assertNdarraysEqual(frame_array[:height, :width], array[:height, :width]) + assertNdarraysEqual(frame_array[height:, :uv_width], array[height:, :uv_width]) + assertNdarraysEqual( + frame_array[height:, uv_width:], + array[height:, uv_stride : uv_stride + uv_width], + ) + + +def test_shares_memory_yuvj420p() -> None: + array = numpy.random.randint(0, 256, size=(512 * 6 // 4, 256), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "yuvj420p") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test with padding, just as we did in the yuv420p case + height = 512 + stride = 256 + width = 200 + array = numpy.random.randint( + 0, 256, size=(height * 6 // 4, stride), dtype=numpy.uint8 + ) + uv_width = width // 2 + uv_stride = stride // 2 + + # compare carefully, avoiding all the padding bytes which to_ndarray strips out + frame = VideoFrame.from_numpy_buffer(array, "yuvj420p", width=width) + frame_array = frame.to_ndarray() + assertNdarraysEqual(frame_array[:height, :width], array[:height, :width]) + assertNdarraysEqual(frame_array[height:, :uv_width], array[height:, :uv_width]) + assertNdarraysEqual( + frame_array[height:, uv_width:], + array[height:, uv_stride : uv_stride + uv_width], + ) + + # overwrite the array, and check the shared frame buffer changed too! + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + frame_array = frame.to_ndarray() + assertNdarraysEqual(frame_array[:height, :width], array[:height, :width]) + assertNdarraysEqual(frame_array[height:, :uv_width], array[height:, :uv_width]) + assertNdarraysEqual( + frame_array[height:, uv_width:], + array[height:, uv_stride : uv_stride + uv_width], + ) + + +def test_shares_memory_nv12() -> None: + array = numpy.random.randint(0, 256, size=(512 * 6 // 4, 256), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "nv12") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(512 * 6 // 4, 256), dtype=numpy.uint8) + array = array[:, :200] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "nv12") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_bgr24() -> None: + array = numpy.random.randint(0, 256, size=(357, 318, 3), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "bgr24") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318, 3), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318, 3), dtype=numpy.uint8) + array = array[:, :300, :] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "bgr24") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_shares_memory_bgra() -> None: + array = numpy.random.randint(0, 256, size=(357, 318, 4), dtype=numpy.uint8) + frame = VideoFrame.from_numpy_buffer(array, "bgra") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=(357, 318, 4), dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + # repeat the test, but with an array that is not fully contiguous, though the + # pixels in a row are + array = numpy.random.randint(0, 256, size=(357, 318, 4), dtype=numpy.uint8) + array = array[:, :300, :] + assert not array.data.c_contiguous + frame = VideoFrame.from_numpy_buffer(array, "bgra") + assertNdarraysEqual(frame.to_ndarray(), array) + + # overwrite the array, the contents thereof + array[...] = numpy.random.randint(0, 256, size=array.shape, dtype=numpy.uint8) + # Make sure the frame reflects that + assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_reformat_pts() -> None: + frame = VideoFrame(640, 480, "rgb24") + frame.pts = 123 + frame.time_base = Fraction("456/1") + frame = frame.reformat(320, 240) + assert frame.pts == 123 and frame.time_base == 456 + + +def test_reformat_identity() -> None: + frame1 = VideoFrame(640, 480, "rgb24") + frame2 = frame1.reformat(640, 480, "rgb24") + assert frame1 is frame2 + + +def test_reformat_colorspace() -> None: + frame = VideoFrame(640, 480, "rgb24") + frame.reformat(src_colorspace=None, dst_colorspace="smpte240m") + + frame = VideoFrame(640, 480, "rgb24") + frame.reformat(src_colorspace=None, dst_colorspace=Colorspace.smpte240m) + + frame = VideoFrame(640, 480, "yuv420p") + frame.reformat(src_colorspace=None, dst_colorspace="smpte240m") + + frame = VideoFrame(640, 480, "rgb24") + frame.colorspace = Colorspace.smpte240m + assert frame.colorspace == int(Colorspace.smpte240m) + assert frame.colorspace == Colorspace.smpte240m + + +def test_reformat_pixel_format_align() -> None: + height = 480 + for width in range(2, 258, 2): + frame_yuv = VideoFrame(width, height, "yuv420p") + for plane in frame_yuv.planes: + plane.update(b"\xff" * plane.buffer_size) + + expected_rgb = numpy.zeros(shape=(height, width, 3), dtype=numpy.uint8) + expected_rgb[:, :, 0] = 255 + expected_rgb[:, :, 1] = 124 + expected_rgb[:, :, 2] = 255 + + frame_rgb = frame_yuv.reformat(format="rgb24") + result = frame_rgb.to_ndarray() + assert result.shape == expected_rgb.shape + assert numpy.abs(result.astype(int) - expected_rgb.astype(int)).max() <= 1