diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b2ab8f9..c49bf3f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,6 @@ updates: interval: "monthly" - package-ecosystem: "cargo" - directory: "/native" + directory: "/av" schedule: interval: "monthly" diff --git a/.github/workflows/build-av.yml b/.github/workflows/build-av.yml new file mode 100644 index 0000000..86e2d6a --- /dev/null +++ b/.github/workflows/build-av.yml @@ -0,0 +1,195 @@ +# Callers run build-python.yml first: validation installs its dist-python wheel. +name: Build tensorcodec-av +on: + workflow_call: +permissions: + contents: read +jobs: + linux: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64 + - runner: ubuntu-24.04-arm + target: aarch64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.2.0 + with: + python-version: '3.12' + - uses: actions/cache/restore@v6 + id: native-cache + with: + path: .native-deps + key: manylinux2014-${{ matrix.target }}-${{ hashFiles('scripts/build_nasm.sh', 'scripts/build_openssl.sh', 'scripts/build_dav1d.sh', 'scripts/build_ffmpeg.sh') }} + - name: Build manylinux2014 wheel + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + manylinux: '2014' + command: build + args: -m av/Cargo.toml --release --locked --auditwheel repair --out dist + before-script-linux: | + /opt/python/cp310-cp310/bin/python -m pip install libclang==18.1.1 + export LIBCLANG_PATH="$(/opt/python/cp310-cp310/bin/python -c 'from clang import cindex; print(cindex.Config.library_path)')" + export BINDGEN_EXTRA_CLANG_ARGS="-I$(gcc -print-file-name=include)" + prefix="$PWD/.native-deps" + export PKG_CONFIG_PATH="$prefix/openssl/lib/pkgconfig" + export LD_LIBRARY_PATH="$prefix/openssl/lib:$prefix/ffmpeg/lib" + if [ ! -f "$prefix/ready" ]; then + yum install -y perl-core + /opt/python/cp310-cp310/bin/python -m pip install meson==1.12.1 ninja==1.13.2 + export PATH="/opt/python/cp310-cp310/bin:$PATH" + if [ "$(uname -m)" = x86_64 ]; then + bash scripts/build_nasm.sh "$prefix/nasm" + export PATH="$prefix/nasm/bin:$PATH" + fi + bash scripts/build_openssl.sh "$prefix/openssl" + bash scripts/build_ffmpeg.sh "$prefix/ffmpeg" + touch "$prefix/ready" + fi + export FFMPEG_DIR="$prefix/ffmpeg" + - name: Check native cache completeness + if: always() + id: native-ready + run: | + if [ -f .native-deps/ready ]; then echo 'ready=true' >> "$GITHUB_OUTPUT"; fi + - uses: actions/cache/save@v6 + if: always() && steps.native-ready.outputs.ready == 'true' && steps.native-cache.outputs.cache-hit != 'true' + with: + path: .native-deps + key: ${{ steps.native-cache.outputs.cache-primary-key }} + - name: Check final wheel size + run: uv run --no-project python scripts/check_wheel_size.py dist/*.whl --output reports/wheel-size.json + - uses: actions/upload-artifact@v7 + if: always() + with: + name: wheel-size-linux-${{ matrix.target }} + path: reports/wheel-size.json + if-no-files-found: warn + - uses: actions/download-artifact@v8 + with: + name: dist-python + path: python-dist + - name: Install validation tools and wheels + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + uv venv + uv pip install pytest numpy twine 'opencv-python-headless>=4.12' 'Pillow>=11.3' dist/*.whl python-dist/*.whl + - name: Generate baseline runtime fixtures + run: uv run --no-sync python scripts/check_wheel_runtime.py generate .runtime-fixtures + - name: Test on glibc 2.17 with Python 3.10 and 3.13 + run: | + docker run --rm -v "$PWD:/io:ro" quay.io/pypa/manylinux2014_${{ matrix.target }} bash -euxc ' + for abi in cp310-cp310 cp313-cp313; do + python="/opt/python/$abi/bin/python" + "$python" -m venv "/tmp/$abi" + runtime="/tmp/$abi/bin/python" + if [ "$abi" = cp310-cp310 ]; then "$runtime" -m pip install --only-binary=:all: numpy==1.26.4; fi + "$runtime" -m pip install --only-binary=:all: /io/dist/*.whl /io/python-dist/*.whl + env -u LD_LIBRARY_PATH "$runtime" /io/scripts/check_wheel_runtime.py check /io/.runtime-fixtures + done + ' + - name: Install pinned reference + run: uv pip install torch==2.14.1 torchcodec==0.17.0 --index-url https://download.pytorch.org/whl/cpu + - name: Use bundled FFmpeg for the reference + run: uv run --no-sync python scripts/configure_oracle_ffmpeg.py + - name: Validate distribution and playback + run: | + uv run --no-sync python -m twine check --strict dist/* + uv run --no-sync pytest tests/test_video_contract.py tests/test_audio_contract.py --backend torchcodec + uv run --no-sync pytest --compare + - uses: actions/upload-artifact@v7 + with: + name: dist-av-linux-${{ matrix.target }} + path: dist/*.whl + if-no-files-found: error + macos: + runs-on: macos-15 + env: + MACOSX_DEPLOYMENT_TARGET: '14.0' + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.2.0 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: av + - uses: prefix-dev/setup-pixi@v0.10.2 + with: + pixi-version: v0.81.0 + run-install: false + global-environments: ffmpeg=7.1.1=gpl_* + global-cache: true + - name: Install build tools + run: | + brew install nasm pkg-config coreutils meson ninja + uv venv + uv pip install numpy pytest 'opencv-python-headless>=4.12' 'Pillow>=11.3' 'maturin>=1.8,<2' delocate twine + echo "$PWD/.venv/bin" >> "$GITHUB_PATH" + echo "LIBCLANG_PATH=$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib" >> "$GITHUB_ENV" + echo "$HOME/.pixi/envs/ffmpeg/bin" >> "$GITHUB_PATH" + - uses: actions/cache/restore@v6 + id: native-cache + with: + path: .native-deps/macos + key: macos14-arm64-${{ hashFiles('scripts/build_openssl.sh', 'scripts/build_dav1d.sh', 'scripts/build_ffmpeg.sh', 'scripts/build_macos_wheel.sh') }} + - name: Build and bundle wheel + run: bash scripts/build_macos_wheel.sh + - uses: actions/cache/save@v6 + if: always() && steps.native-cache.outputs.cache-hit != 'true' && hashFiles('.native-deps/macos/ready') != '' + with: + path: .native-deps/macos + key: ${{ steps.native-cache.outputs.cache-primary-key }} + - name: Hide build-time FFmpeg libraries + run: mv .native-deps/macos .native-deps/macos-build-only + - uses: actions/download-artifact@v8 + with: + name: dist-python + path: python-dist + - name: Validate installed wheels + run: | + uv pip install dist/*.whl python-dist/*.whl + python -m twine check --strict dist/* + pytest + python scripts/check_wheel_runtime.py generate .runtime-fixtures + - name: Test clean Python 3.10 and 3.13 environments + run: | + for version in 3.10 3.13; do + uv venv "/tmp/runtime-$version" --python "$version" + uv pip install --python "/tmp/runtime-$version/bin/python" dist/*.whl python-dist/*.whl + "/tmp/runtime-$version/bin/python" scripts/check_wheel_runtime.py check .runtime-fixtures + done + - name: Restore build cache + if: always() + run: | + if [ -d .native-deps/macos-build-only ]; then + mv .native-deps/macos-build-only .native-deps/macos + fi + - uses: actions/upload-artifact@v7 + with: + name: dist-av-macos-arm64 + path: dist/*.whl + if-no-files-found: error + sdist: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.2.0 + with: + python-version: '3.12' + - name: Build and check source distribution + run: | + uvx --from 'maturin>=1.8,<2' maturin sdist -m av/Cargo.toml --out dist + uvx twine check --strict dist/* + - uses: actions/upload-artifact@v7 + with: + name: dist-av-sdist + path: dist/*.tar.gz + if-no-files-found: error diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml new file mode 100644 index 0000000..3da6e25 --- /dev/null +++ b/.github/workflows/build-python.yml @@ -0,0 +1,68 @@ +name: Build tensorcodec +on: + workflow_call: +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Build and check wheel and sdist + run: | + python -m pip install build twine + python -m build --outdir dist + python -m twine check --strict dist/* + - uses: actions/upload-artifact@v7 + with: + name: dist-python + path: dist/* + if-no-files-found: error + test-without-av: + needs: build + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2025 + python: '3.12' + opencv: 'opencv-python-headless>=4.12' + - runner: macos-15-intel + python: '3.10' + opencv: 'opencv-python-headless==4.12.0.88' # lower bound + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + - uses: actions/download-artifact@v8 + with: + name: dist-python + path: dist + # Fixtures need libaom; Homebrew's FFmpeg lacks it, so use conda-forge's like the other jobs. + - uses: prefix-dev/setup-pixi@v0.10.2 + env: + PIXI_HOME: ${{ runner.temp }}/pixi + with: + pixi-version: v0.81.0 + run-install: false + global-environments: ffmpeg=7.1.1=gpl_* + global-cache: true + - name: Put fixture FFmpeg on PATH + env: + FFMPEG_BIN: ${{ runner.temp }}/pixi/envs/ffmpeg/${{ runner.os == 'Windows' && 'Library/bin' || 'bin' }} + run: echo "$FFMPEG_BIN" >> "$GITHUB_PATH" + - name: Install tensorcodec without tensorcodec-av + run: | + python -m pip install pytest 'Pillow>=11.3' '${{ matrix.opencv }}' + python -m pip install --no-index --find-links dist "tensorcodec[images]" + python -c "import importlib.util as u; assert u.find_spec('tensorcodec_av') is None" + - name: Test image codecs + run: python -m pytest tests/test_images.py tests/test_image_encoders.py tests/test_av_optional.py tests/test_versions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aff15a7..d10de2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,11 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: - macos: - uses: ./.github/workflows/macos-wheels.yml + python: + uses: ./.github/workflows/build-python.yml + av: + needs: python + uses: ./.github/workflows/build-av.yml test: runs-on: ubuntu-24.04 steps: @@ -24,7 +27,7 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 with: - workspaces: native + workspaces: av - uses: prefix-dev/setup-pixi@v0.10.2 with: pixi-version: v0.81.0 @@ -35,7 +38,7 @@ jobs: run: | python -m venv .venv .venv/bin/python -m pip install --upgrade pip - .venv/bin/pip install numpy pytest ruff 'maturin>=1.8,<2' 'opencv-python-headless>=4.13,<5' 'Pillow>=11.3' + .venv/bin/pip install numpy pytest ruff 'maturin>=1.8,<2' 'opencv-python-headless>=4.12' 'Pillow>=11.3' echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - name: Configure prebuilt FFmpeg @@ -51,12 +54,14 @@ jobs: "$prefix/bin/ffmpeg" -version - name: Static checks run: | - ruff check src/tensorcodec tests scripts benchmarks/image_codecs.py - ruff format --check src/tensorcodec tests scripts benchmarks/image_codecs.py - cargo fmt --manifest-path native/Cargo.toml --check - cargo clippy --manifest-path native/Cargo.toml --locked -- -D warnings - - name: Build extension against prebuilt FFmpeg - run: maturin develop --locked + ruff check src/tensorcodec av/python tests scripts benchmarks/image_codecs.py + ruff format --check src/tensorcodec av/python tests scripts benchmarks/image_codecs.py + cargo fmt --manifest-path av/Cargo.toml --check + cargo clippy --manifest-path av/Cargo.toml --locked -- -D warnings + - name: Build tensorcodec-av against prebuilt FFmpeg and install tensorcodec + run: | + maturin develop -m av/Cargo.toml --locked + pip install -e . - name: Install pinned reference run: python -m pip install torch==2.14.1 torchcodec==0.17.0 --index-url https://download.pytorch.org/whl/cpu - name: Validate independent contract against reference diff --git a/.github/workflows/macos-wheels.yml b/.github/workflows/macos-wheels.yml deleted file mode 100644 index 2db8d2d..0000000 --- a/.github/workflows/macos-wheels.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: macOS wheels -on: - workflow_call: -permissions: - contents: read -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15 - arch: arm64 - runs-on: ${{ matrix.runner }} - env: - MACOSX_DEPLOYMENT_TARGET: '14.0' - steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v10.2.0 - with: - python-version: '3.12' - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - workspaces: native - - uses: prefix-dev/setup-pixi@v0.10.2 - with: - pixi-version: v0.81.0 - run-install: false - global-environments: ffmpeg=7.1.1=gpl_* - global-cache: true - - name: Install build tools - run: | - brew install nasm pkg-config coreutils meson ninja - uv venv - uv pip install numpy pytest 'opencv-python-headless>=4.13,<5' 'Pillow>=11.3' 'maturin>=1.8,<2' delocate twine - echo "$PWD/.venv/bin" >> "$GITHUB_PATH" - echo "LIBCLANG_PATH=$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib" >> "$GITHUB_ENV" - echo "$HOME/.pixi/envs/ffmpeg/bin" >> "$GITHUB_PATH" - - uses: actions/cache/restore@v6 - id: native-cache - with: - path: .native-deps/macos - key: macos14-${{ matrix.arch }}-${{ hashFiles('scripts/build_openssl.sh', 'scripts/build_dav1d.sh', 'scripts/build_ffmpeg.sh', 'scripts/build_macos_wheel.sh') }} - - name: Build and bundle wheel - run: bash scripts/build_macos_wheel.sh - - uses: actions/cache/save@v6 - if: always() && steps.native-cache.outputs.cache-hit != 'true' && hashFiles('.native-deps/macos/ready') != '' - with: - path: .native-deps/macos - key: ${{ steps.native-cache.outputs.cache-primary-key }} - - name: Hide build-time FFmpeg libraries - run: mv .native-deps/macos .native-deps/macos-build-only - - name: Validate installed wheel - run: | - uv pip install dist/*.whl - python -m twine check dist/* - pytest - python scripts/check_wheel_runtime.py generate .runtime-fixtures - - name: Test clean Python 3.10 and 3.13 environments - run: | - for version in 3.10 3.13; do - uv venv "/tmp/runtime-$version" --python "$version" - uv pip install --python "/tmp/runtime-$version/bin/python" dist/*.whl - "/tmp/runtime-$version/bin/python" scripts/check_wheel_runtime.py check .runtime-fixtures - done - - name: Restore build cache - if: always() - run: | - if [ -d .native-deps/macos-build-only ]; then - mv .native-deps/macos-build-only .native-deps/macos - fi - - uses: actions/upload-artifact@v7 - with: - name: pypi-distributions-macos-${{ matrix.arch }} - path: dist/*.whl - if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b85973d..827f206 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,112 +12,13 @@ concurrency: group: pypi-publish cancel-in-progress: false jobs: - macos: - uses: ./.github/workflows/macos-wheels.yml - build: - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - target: x86_64 - - runner: ubuntu-24.04-arm - target: aarch64 - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v10.2.0 - with: - python-version: '3.12' - - uses: actions/cache/restore@v6 - id: native-cache - with: - path: .native-deps - key: manylinux2014-${{ matrix.target }}-${{ hashFiles('scripts/build_nasm.sh', 'scripts/build_openssl.sh', 'scripts/build_dav1d.sh', 'scripts/build_ffmpeg.sh') }} - - name: Build portable Linux wheel - uses: PyO3/maturin-action@v1 - with: - target: ${{ matrix.target }} - manylinux: '2014' - command: build - args: --release --locked --auditwheel repair --out dist - before-script-linux: | - /opt/python/cp310-cp310/bin/python -m pip install libclang==18.1.1 - export LIBCLANG_PATH="$(/opt/python/cp310-cp310/bin/python -c 'from clang import cindex; print(cindex.Config.library_path)')" - export BINDGEN_EXTRA_CLANG_ARGS="-I$(gcc -print-file-name=include)" - prefix="$PWD/.native-deps" - export PKG_CONFIG_PATH="$prefix/openssl/lib/pkgconfig" - export LD_LIBRARY_PATH="$prefix/openssl/lib:$prefix/ffmpeg/lib" - if [ ! -f "$prefix/ready" ]; then - yum install -y perl-core - /opt/python/cp310-cp310/bin/python -m pip install meson==1.12.1 ninja==1.13.2 - export PATH="/opt/python/cp310-cp310/bin:$PATH" - if [ "$(uname -m)" = x86_64 ]; then - bash scripts/build_nasm.sh "$prefix/nasm" - export PATH="$prefix/nasm/bin:$PATH" - fi - bash scripts/build_openssl.sh "$prefix/openssl" - bash scripts/build_ffmpeg.sh "$prefix/ffmpeg" - touch "$prefix/ready" - fi - export FFMPEG_DIR="$prefix/ffmpeg" - - name: Check native cache completeness - if: always() - id: native-ready - run: | - if [ -f .native-deps/ready ]; then echo 'ready=true' >> "$GITHUB_OUTPUT"; fi - - uses: actions/cache/save@v6 - if: always() && steps.native-ready.outputs.ready == 'true' && steps.native-cache.outputs.cache-hit != 'true' - with: - path: .native-deps - key: ${{ steps.native-cache.outputs.cache-primary-key }} - - name: Check final wheel size - run: uv run --no-project python scripts/check_wheel_size.py dist/*.whl --output reports/wheel-size.json - - uses: actions/upload-artifact@v7 - if: always() - with: - name: wheel-size-${{ matrix.target }} - path: reports/wheel-size.json - if-no-files-found: warn - - name: Build source distribution - if: matrix.target == 'x86_64' - run: uvx --from 'maturin>=1.8,<2' maturin sdist --out dist - - name: Install validation tools and wheel - run: | - sudo apt-get update - sudo apt-get install -y ffmpeg - uv venv - uv pip install pytest numpy twine 'opencv-python-headless>=4.13,<5' 'Pillow>=11.3' dist/*.whl - - name: Generate baseline runtime fixtures - run: uv run --no-sync python scripts/check_wheel_runtime.py generate .runtime-fixtures - - name: Test on glibc 2.17 with Python 3.10 and 3.13 - run: | - docker run --rm -v "$PWD:/io:ro" quay.io/pypa/manylinux2014_${{ matrix.target }} bash -euxc ' - for abi in cp310-cp310 cp313-cp313; do - python="/opt/python/$abi/bin/python" - "$python" -m venv "/tmp/$abi" - runtime="/tmp/$abi/bin/python" - if [ "$abi" = cp310-cp310 ]; then "$runtime" -m pip install --only-binary=:all: numpy==1.26.4; fi - "$runtime" -m pip install --only-binary=:all: /io/dist/*.whl - env -u LD_LIBRARY_PATH "$runtime" /io/scripts/check_wheel_runtime.py check /io/.runtime-fixtures - done - ' - - name: Install pinned reference - run: uv pip install torch==2.14.1 torchcodec==0.17.0 --index-url https://download.pytorch.org/whl/cpu - - name: Use bundled FFmpeg for the reference - run: uv run --no-sync python scripts/configure_oracle_ffmpeg.py - - name: Validate distribution and playback - run: | - uv run --no-sync python -m twine check dist/* - uv run --no-sync pytest tests/test_video_contract.py tests/test_audio_contract.py --backend torchcodec - uv run --no-sync pytest --compare - - uses: actions/upload-artifact@v7 - with: - name: pypi-distributions-${{ matrix.target }} - path: dist/* - if-no-files-found: error + python: + uses: ./.github/workflows/build-python.yml + av: + needs: python + uses: ./.github/workflows/build-av.yml publish: - needs: [build, macos] + needs: [python, av] if: inputs.publish runs-on: ubuntu-24.04 environment: @@ -129,10 +30,22 @@ jobs: steps: - uses: actions/download-artifact@v8 with: - pattern: pypi-distributions-* + pattern: dist-av-* merge-multiple: true - path: dist - - uses: pypa/gh-action-pypi-publish@release/v1 + path: av-dist + - uses: actions/download-artifact@v8 + with: + name: dist-python + path: python-dist + # tensorcodec-av first: tensorcodec pins tensorcodec-av exactly, so it must not appear before its pin exists. + - name: Publish tensorcodec-av + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: av-dist/ + - name: Publish tensorcodec + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: python-dist/ update-size-docs: needs: publish if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/source.yml b/.github/workflows/source.yml deleted file mode 100644 index 7b37b90..0000000 --- a/.github/workflows/source.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Source artifact -on: - workflow_dispatch: -permissions: - contents: read -jobs: - source: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: '3.12' - - run: | - python -m pip install 'maturin>=1.8,<2' - maturin sdist --out dist - - uses: actions/upload-artifact@v7 - with: - name: tensorcodec-source - path: dist/*.tar.gz diff --git a/.gitignore b/.gitignore index a4e80d5..b31b79e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # ============================================================================ # Rust build artifacts -/native/target/ +/av/target/ /.ffmpeg/ /.native-deps/ *.pyd diff --git a/README.md b/README.md index bed53c0..9f4b152 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,11 @@ See the [compatibility contract](docs/compatibility.md) and - **Wheels:** Linux x86_64 and ARM64 (aarch64), glibc 2.17+, CPython 3.10+. NumPy must also provide a compatible wheel; newer Python versions may require - a newer glibc. macOS 14+ wheels support Apple Silicon (Intel Macs: through 0.1.5). Windows, musl/Alpine - and free-threaded Python wheels are not provided. + a newer glibc. macOS 14+ wheels support Apple Silicon (Intel Macs: through 0.1.5). + These are `tensorcodec-av` wheels, installed automatically with `tensorcodec` (pure Python). + Elsewhere only image codecs work; to build video/audio from source, install + `tensorcodec-av==` ([external FFmpeg](docs/system_ffmpeg.md)). musl and + free-threaded CPython cannot be told apart by markers, so there use `pip install --no-deps tensorcodec numpy`. - **Exact seeking:** scans packet timestamps when opening the decoder. Incorrect container keyframe flags can produce corrupt frames; repaired input or corrected frame mappings are needed in that case. @@ -175,7 +178,7 @@ See [container behavior](docs/container_robustness.md) for seek limitations and
Build from source and run tests -Source builds require Rust 1.88+, Clang/libclang, pkg-config and FFmpeg 7 development +Source builds of `av/` (`tensorcodec-av`) require Rust 1.88+, Clang/libclang, pkg-config and FFmpeg 7 development headers/libraries. Python handles API and playback selection; Rust + PyO3 handles FFmpeg. Native decoding releases the GIL, allowing separate decoder instances to run concurrently across Python threads. Calls on the same instance are serialized. @@ -189,13 +192,13 @@ uv run --group oracle pytest tests/test_video_contract.py tests/test_audio_contr uv run --group oracle pytest --compare # Rebuild after changing Rust code. -uv run --group oracle maturin develop --locked --uv +uv run --group oracle maturin develop -m av/Cargo.toml --locked --uv ``` Tests generate media with FFmpeg/ffprobe and Python's `wave` module. `--compare` requires the pinned oracle; differential tests otherwise skip. -[Playback rules](docs/playback_semantics.md) · [Release guide](docs/releasing.md) · [Dependency licenses](licenses/README.md) +[Playback rules](docs/playback_semantics.md) · [Release guide](docs/releasing.md) · [Dependency licenses](av/licenses/README.md)
diff --git a/native/Cargo.lock b/av/Cargo.lock similarity index 99% rename from native/Cargo.lock rename to av/Cargo.lock index 1fc1a76..571a4fc 100644 --- a/native/Cargo.lock +++ b/av/Cargo.lock @@ -448,8 +448,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] -name = "tensorcodec-native" -version = "0.2.0" +name = "tensorcodec-av" +version = "0.3.0" dependencies = [ "ffmpeg-sys-next", "libc", diff --git a/native/Cargo.toml b/av/Cargo.toml similarity index 86% rename from native/Cargo.toml rename to av/Cargo.toml index 43dbdf1..a4da9b8 100644 --- a/native/Cargo.toml +++ b/av/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "tensorcodec-native" -version = "0.2.0" +name = "tensorcodec-av" +version = "0.3.0" edition = "2021" rust-version = "1.88" # ffmpeg-sys-next 9 builds with let chains license = "Apache-2.0" [lib] -name = "_native" +name = "_av" crate-type = ["cdylib"] [dependencies] diff --git a/av/LICENSE b/av/LICENSE new file mode 100644 index 0000000..77d7acf --- /dev/null +++ b/av/LICENSE @@ -0,0 +1,204 @@ +Copyright (c) 2026 Suhwan Choi + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/av/README.md b/av/README.md new file mode 100644 index 0000000..27ded41 --- /dev/null +++ b/av/README.md @@ -0,0 +1,5 @@ +# tensorcodec-av + +FFmpeg extension behind [TensorCodec](https://github.com/MilkClouds/tensorcodec)'s +`VideoDecoder` and `AudioDecoder`; install `tensorcodec`, which depends on the matching version. +Bundled library licenses: [`licenses/README.md`](https://github.com/MilkClouds/tensorcodec/blob/main/av/licenses/README.md). diff --git a/licenses/FFmpeg-GPL-3.0.txt b/av/licenses/FFmpeg-GPL-3.0.txt similarity index 100% rename from licenses/FFmpeg-GPL-3.0.txt rename to av/licenses/FFmpeg-GPL-3.0.txt diff --git a/licenses/FFmpeg-LGPL-3.0.txt b/av/licenses/FFmpeg-LGPL-3.0.txt similarity index 100% rename from licenses/FFmpeg-LGPL-3.0.txt rename to av/licenses/FFmpeg-LGPL-3.0.txt diff --git a/licenses/FFmpeg-NOTICE.md b/av/licenses/FFmpeg-NOTICE.md similarity index 100% rename from licenses/FFmpeg-NOTICE.md rename to av/licenses/FFmpeg-NOTICE.md diff --git a/licenses/OpenSSL.txt b/av/licenses/OpenSSL.txt similarity index 100% rename from licenses/OpenSSL.txt rename to av/licenses/OpenSSL.txt diff --git a/licenses/README.md b/av/licenses/README.md similarity index 91% rename from licenses/README.md rename to av/licenses/README.md index 664e922..85fcf83 100644 --- a/licenses/README.md +++ b/av/licenses/README.md @@ -1,6 +1,6 @@ # Bundled native libraries -TensorCodec's own code is Apache-2.0 licensed. Linux and macOS wheels bundle shared FFmpeg +TensorCodec's own code is Apache-2.0 licensed. `tensorcodec-av` Linux and macOS wheels bundle shared FFmpeg 7.1.5 libraries, built without GPL codec libraries using `scripts/build_ffmpeg.sh`. This configuration is LGPL-3.0-or-later. Its notices and both the LGPLv3 and incorporated GPLv3 texts are included here. The exact diff --git a/licenses/Zstandard.txt b/av/licenses/Zstandard.txt similarity index 100% rename from licenses/Zstandard.txt rename to av/licenses/Zstandard.txt diff --git a/licenses/dav1d.txt b/av/licenses/dav1d.txt similarity index 100% rename from licenses/dav1d.txt rename to av/licenses/dav1d.txt diff --git a/av/pyproject.toml b/av/pyproject.toml new file mode 100644 index 0000000..89a8701 --- /dev/null +++ b/av/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "tensorcodec-av" +# Version comes from Cargo.toml and must equal tensorcodec's (tests/test_versions.py). +dynamic = ["version"] +description = "FFmpeg-based audio/video decoders for tensorcodec" +readme = "README.md" +license = "Apache-2.0 AND LGPL-3.0-or-later AND BSD-2-Clause" +license-files = ["LICENSE", "licenses/*"] +authors = [{ name = "Suhwan Choi", email = "milkclouds00@gmail.com" }] +requires-python = ">=3.10" +dependencies = ["numpy>=1.26"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python :: 3", + "Programming Language :: Rust", + "Topic :: Multimedia :: Video", +] + +[project.urls] +Repository = "https://github.com/MilkClouds/tensorcodec" +Issues = "https://github.com/MilkClouds/tensorcodec/issues" + +[build-system] +requires = ["maturin>=1.8,<2"] +build-backend = "maturin" + +[tool.maturin] +python-source = "python" +module-name = "tensorcodec_av._av" +features = ["pyo3/extension-module"] diff --git a/av/python/tensorcodec_av/__init__.py b/av/python/tensorcodec_av/__init__.py new file mode 100644 index 0000000..af91cb2 --- /dev/null +++ b/av/python/tensorcodec_av/__init__.py @@ -0,0 +1 @@ +"""FFmpeg extension used by tensorcodec's video and audio decoders; not a public API.""" diff --git a/native/src/ffmpeg.rs b/av/src/ffmpeg.rs similarity index 100% rename from native/src/ffmpeg.rs rename to av/src/ffmpeg.rs diff --git a/native/src/lib.rs b/av/src/lib.rs similarity index 98% rename from native/src/lib.rs rename to av/src/lib.rs index 48a7951..7cba589 100644 --- a/native/src/lib.rs +++ b/av/src/lib.rs @@ -134,9 +134,10 @@ fn closed() -> PyErr { } #[pymodule] -fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { +fn _av(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add("ffmpeg_version", ffmpeg::version())?; + module.add("__version__", env!("CARGO_PKG_VERSION"))?; Ok(()) } diff --git a/docs/images.md b/docs/images.md index c31d087..4b693be 100644 --- a/docs/images.md +++ b/docs/images.md @@ -1,6 +1,6 @@ # Image codecs -Decode JPEG, PNG, WebP, GIF and AVIF into NumPy arrays, and encode grayscale or +Decode JPEG, PNG, WebP, GIF, AVIF and BMP into NumPy arrays, and encode grayscale or RGB arrays as JPEG or PNG. Decoding returns CHW images or NCHW animations on CPU. ```sh @@ -20,12 +20,15 @@ encoded = JpegEncoder(rgb).to_tensor(quality=90) # 1-D uint8 NumPy array ## Installation -The current image backend uses OpenCV 4.13+. An existing compatible `cv2` -installation is sufficient; otherwise the `images` extra installs +The image backend uses OpenCV 4.12+ (tested with 4.12, 4.13 and 5.0); earlier +`opencv-python-headless` wheels lack the GIF and AVIF decoders. An existing compatible +`cv2` installation is sufficient; otherwise the `images` extra installs `opencv-python-headless`. Use only one OpenCV wheel variant per environment. NumPy is the only required dependency for the base package. Image dependencies are separate from the base wheel size. Pillow is used only in tests. +Image codecs do not need `tensorcodec-av`, so they also work on platforms without its wheels. + ## Contract and limits - `decode_image`, `decode_jpeg`, `decode_png`, `decode_webp`, `decode_gif`, @@ -44,8 +47,11 @@ are separate from the base wheel size. Pillow is used only in tests. - AVIF color conversion follows OpenCV. Dropping alpha preserves straight RGB; TorchCodec 0.17.0 premultiplies AVIF RGB in that case. Pixel identity with TorchCodec is not promised across formats, builds or codec versions. -- HEIC is unsupported. -- Other formats depend on the installed OpenCV build. Missing dependencies, +- `decode_image` also detects BMP; as in Pillow, only 32-bit `BI_BITFIELDS` + BMPs with an alpha mask keep alpha. +- HEIC and TIFF (OpenCV alters unassociated alpha) are unsupported. +- Other formats depend on the installed OpenCV build (the Windows wheel has no + AVIF decoder). Missing dependencies, unsupported codecs and decode failures raise; no alternate decoder is tried. - Encoders accept nonempty CHW uint8 arrays with 1 or 3 channels. Both provide `to_file`, `to_file_like` and `to_tensor`; JPEG quality is 1–100 (default 75), diff --git a/docs/package_size.md b/docs/package_size.md index b8a2981..60adf23 100644 --- a/docs/package_size.md +++ b/docs/package_size.md @@ -1,7 +1,7 @@ # Package size policy TensorCodec keeps its NumPy-only Python dependency set and bundles a minimal -FFmpeg/OpenSSL runtime in its default Linux wheels. Size limits prevent additions +FFmpeg/OpenSSL runtime in its default Linux `tensorcodec-av` wheels. Size limits prevent additions from silently increasing the distributed binary footprint. ## What is measured @@ -24,10 +24,10 @@ uv run --no-project python scripts/check_wheel_size.py dist/*.whl --output repor ``` Each architecture is checked independently. Exactly reaching a limit passes; -exceeding either limit fails. The release workflow runs this after `auditwheel -repair`, before uploading distributions. JSON reports are separate artifacts, not +exceeding either limit fails. `build-av.yml` runs this after `auditwheel +repair`, in CI and before uploading distributions. JSON reports are separate artifacts, not files in `dist/`. Actions summaries include changes from the committed published -baseline. Ordinary CI tests the checker without building FFmpeg from source. +baseline. Before changing a limit, explain the feature, the measured byte increase on both architectures and why a smaller configuration would not provide the same behavior. diff --git a/docs/releasing.md b/docs/releasing.md index de48728..a0c1a91 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,19 +1,25 @@ # Publishing TensorCodec -Release version: `0.2.0`. Distribution and import name: `tensorcodec`. -Binary wheels target Linux x86_64 and ARM64 (aarch64), glibc 2.17+, CPython 3.10+. +Release version: `0.3.0`. Each release publishes two PyPI projects at the same version: +`tensorcodec` (pure Python, built by hatchling) and `tensorcodec-av` (the FFmpeg +extension in `av/`, built by maturin). `tensorcodec` pins `tensorcodec-av==` +behind a platform marker; bump the version in `pyproject.toml` (twice), `av/Cargo.toml`, +`av/Cargo.lock` and `src/tensorcodec/__init__.py` (`tests/test_versions.py` checks). + +`tensorcodec-av` wheels target Linux x86_64 and ARM64 (aarch64), glibc 2.17+, CPython 3.10+. NumPy must also provide a compatible wheel for the selected Python/glibc pair. The wheel bundles shared FFmpeg 7.1.5 and OpenSSL 3.5.9 LTS; its only Python runtime dependency is NumPy. macOS 14+ ARM64 wheels bundle the same minimal -runtime. Windows wheels are not provided. +runtime. Windows wheels are not provided; there `tensorcodec` has image codecs only. ## Trusted publisher configuration -The PyPI project is already registered. Its GitHub Trusted Publisher uses: +Both PyPI projects use this GitHub Trusted Publisher. Before the first release of +`tensorcodec-av`, add it as a pending publisher with the same fields. | Field | Value | | --- | --- | -| PyPI project name | `tensorcodec` | +| PyPI project names | `tensorcodec`, `tensorcodec-av` | | GitHub owner | `epishelf` | | Repository | `tensorcodec` | | Workflow filename | `publish.yml` | @@ -25,10 +31,11 @@ is needed. Repository visibility does not need to change for a release. ## Release -Run the **Publish to PyPI** workflow on `main`. It builds the portable Linux/macOS wheels -and source distribution, checks package metadata, validates the pinned oracle -and compares playback before uploading through PyPI Trusted Publishing. It uses -the existing GitHub `pypi` environment. Publication fails if authorization is +Run the **Publish to PyPI** workflow on `main`. It calls `build-python.yml` and +`build-av.yml` (the same builds and validation CI runs), then uploads `tensorcodec-av` before +`tensorcodec`, so the exact pin never points at a missing release. If only the +second upload fails, `twine upload` the run's `dist-python` artifact. It uses the +existing GitHub `pypi` environment. Publication fails if authorization is missing, tests fail, or the version has already been uploaded. ```sh @@ -37,26 +44,26 @@ gh workflow run publish.yml --repo epishelf/tensorcodec --ref main For a build and full validation without uploading, pass `--field publish=false`. -Check the workflow and https://pypi.org/project/tensorcodec/0.2.0/ before reporting -success. Verify a fresh `uv pip install tensorcodec==0.2.0` and a decode without -Torch/PyAV on both architectures. Update the version before subsequent releases; +Check the workflow and both PyPI project pages before reporting success. Verify a +fresh `uv pip install tensorcodec==` pulls `tensorcodec-av` and decodes +without Torch/PyAV on both Linux architectures and macOS arm64. Update the version before subsequent releases; PyPI versions cannot be overwritten. The local Linux build is reproducible using `scripts/build_linux_wheel.sh` inside `quay.io/pypa/manylinux2014_x86_64` or `quay.io/pypa/manylinux2014_aarch64` with Rust, maturin, libclang, NASM and Perl. Both native source archives are version- and checksum-pinned. Their licensing -and source links are recorded in `licenses/README.md`. +and source links are recorded in `av/licenses/README.md`. ## CI versus release builds - Ordinary CI uses prebuilt conda-forge FFmpeg 7.1.1 through Pixi, including its - headers and shared libraries. It builds only the TensorCodec extension. -- PyPI wheels use the smaller LGPL FFmpeg 7.1.5 build plus OpenSSL 3.5.9. + headers and shared libraries. It builds only the `tensorcodec-av` extension. +- PyPI wheels (`build-av.yml`, also run by CI) use the smaller LGPL FFmpeg 7.1.5 build plus OpenSSL 3.5.9. Their native prefix is cached by architecture, glibc baseline and build-script checksums. This preserves the wheel's codec set, dependency size and licensing rather than bundling the full conda-forge dependency graph. -- Release validation installs each repaired wheel on glibc 2.17 with Python 3.10 +- Release validation installs each repaired wheel with the `tensorcodec` wheel on glibc 2.17 with Python 3.10 and 3.13 and decodes video/audio without Torch, PyAV or a system FFmpeg. Python 3.10 also checks the minimum NumPy line (1.26.4). Native x86_64 and ARM64 runners also run the full pinned playback oracle comparison. @@ -71,7 +78,7 @@ separate `wheel-size-*` report, including differences from the last published baseline. Size reports must not be placed in `dist/`. After a successful publication, the `update-size-docs` job measures hash-verified -PyPI wheels for the release and pinned TorchCodec 0.17.0. It commits the published +PyPI `tensorcodec-av` wheels for the release and pinned TorchCodec 0.17.0. It commits the published snapshot and README table/badge with a normal push to `main`. Only this documentation job has `contents: write`; the PyPI publisher retains OIDC plus read access. The repository must permit the Actions bot to push these documentation updates. diff --git a/docs/system_ffmpeg.md b/docs/system_ffmpeg.md index 72fb23a..0d4ae30 100644 --- a/docs/system_ffmpeg.md +++ b/docs/system_ffmpeg.md @@ -7,7 +7,7 @@ uv venv uv pip install tensorcodec ``` -Supported Linux wheels include minimal shared FFmpeg 7.1.5 (with dav1d for AV1) and OpenSSL libraries. +Supported platforms get `tensorcodec-av` wheels with minimal shared FFmpeg 7.1.5 (with dav1d for AV1) and OpenSSL libraries. No FFmpeg CLI, Pixi, Rust or libclang is required at runtime. This is the recommended installation for a new environment. @@ -37,9 +37,12 @@ test -f "$FFMPEG_DIR/include/libavcodec/avcodec.h" test -f "$FFMPEG_DIR/lib/libavcodec.so.61" uv venv -uv pip install --no-binary tensorcodec 'tensorcodec==0.1.5' +uv pip install 'tensorcodec==0.3.0' 'tensorcodec-av==0.3.0' --no-binary tensorcodec-av ``` +Only `tensorcodec-av` is built from source; naming it also covers platforms +without its wheels, such as Intel macOS. + The version/build constraint avoids silently selecting an incompatible FFmpeg major. `FFMPEG_DIR` tells the source build where to find headers and libraries; `LD_LIBRARY_PATH` tells the Linux loader where to find the libraries at runtime. @@ -49,14 +52,14 @@ paths, also set `LIBCLANG_PATH` to its library directory. The example uses a GPL-enabled conda-forge build, unlike the minimal LGPL release build. Its additional codecs, dependencies and licensing apply to your environment. -Review [`licenses/README.md`](../licenses/README.md) before redistributing a binary +Review [`av/licenses/README.md`](../av/licenses/README.md) before redistributing a binary built against a different FFmpeg configuration. For development against the same prefix: ```sh uv sync --group dev -uv run maturin develop --locked --uv +uv run maturin develop -m av/Cargo.toml --locked --uv ``` Do not reuse an existing `dist/` wheel while verifying this path: it may contain the diff --git a/pyproject.toml b/pyproject.toml index 9ed776c..8e98b4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,51 +1,46 @@ [project] name = "tensorcodec" -version = "0.2.0" +version = "0.3.0" description = "Video, audio and image codecs with TorchCodec-style APIs and NumPy arrays" readme = "README.md" license = "Apache-2.0" -license-files = ["LICENSE", "licenses/*"] +license-files = ["LICENSE"] authors = [{ name = "Suhwan Choi", email = "milkclouds00@gmail.com" }] requires-python = ">=3.10" -dependencies = ["numpy>=1.26"] +# Exact pin; the marker follows the tensorcodec-av wheel tags. No platform_release check (macOS 14): +# pip's packaging 22-25 raises on Linux kernel releases that are not PEP 440 versions. +dependencies = [ + "numpy>=1.26", + "tensorcodec-av==0.3.0; platform_python_implementation == 'CPython' and ((sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))", +] classifiers = [ "Development Status :: 3 - Alpha", - "Operating System :: POSIX :: Linux", - "Operating System :: MacOS :: MacOS X", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Rust", + "Topic :: Multimedia :: Graphics", "Topic :: Multimedia :: Video", ] [project.optional-dependencies] -images = ["opencv-python-headless>=4.13,<5"] +images = ["opencv-python-headless>=4.12"] [project.urls] Repository = "https://github.com/MilkClouds/tensorcodec" Issues = "https://github.com/MilkClouds/tensorcodec/issues" [dependency-groups] -dev = ["pytest>=8", "ruff>=0.9", "maturin>=1.8,<2", "opencv-python-headless>=4.13,<5", "Pillow>=11.3"] +dev = ["pytest>=8", "ruff>=0.9", "maturin>=1.8,<2", "opencv-python-headless>=4.12", "Pillow>=11.3"] oracle = ["torch==2.14.1", "torchcodec==0.17.0"] [build-system] -requires = ["maturin>=1.8,<2"] -build-backend = "maturin" +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" -[tool.maturin] -manifest-path = "native/Cargo.toml" -python-source = "src" -module-name = "tensorcodec._native" -features = ["pyo3/extension-module"] -include = [ - { path = "scripts/*.sh", format = "sdist" }, - { path = "scripts/*.py", format = "sdist" }, - { path = "docs/*.md", format = "sdist" }, - { path = "packaging/*.json", format = "sdist" }, - { path = "tests/**/*.py", format = "sdist" }, - { path = "benchmarks/**/*.py", format = "sdist" }, - { path = "benchmarks/*.md", format = "sdist" }, -] +[tool.hatch.build.targets.wheel] +packages = ["src/tensorcodec"] + +[tool.hatch.build.targets.sdist] +only-include = ["src", "av", "tests", "docs", "scripts", "benchmarks", "packaging", "README.md", "LICENSE"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -55,6 +50,7 @@ line-length = 119 target-version = "py310" [tool.uv.sources] +tensorcodec-av = { path = "av", editable = true } torch = { index = "pytorch-cpu" } torchcodec = { index = "pytorch-cpu" } diff --git a/scripts/build_linux_wheel.sh b/scripts/build_linux_wheel.sh index 8123a9d..85ab0be 100755 --- a/scripts/build_linux_wheel.sh +++ b/scripts/build_linux_wheel.sh @@ -9,5 +9,5 @@ export LD_LIBRARY_PATH="$build_prefix/openssl/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY scripts/build_ffmpeg.sh "$build_prefix/ffmpeg" export FFMPEG_DIR="$build_prefix/ffmpeg" export LD_LIBRARY_PATH="$FFMPEG_DIR/lib:$LD_LIBRARY_PATH" -maturin build --release --locked --auditwheel repair --compatibility manylinux2014 --out dist +maturin build -m av/Cargo.toml --release --locked --auditwheel repair --compatibility manylinux2014 --out dist python scripts/check_wheel_size.py dist/*.whl diff --git a/scripts/build_macos_wheel.sh b/scripts/build_macos_wheel.sh index db681a3..8982ce0 100644 --- a/scripts/build_macos_wheel.sh +++ b/scripts/build_macos_wheel.sh @@ -11,6 +11,6 @@ if [ ! -f "$build_prefix/ready" ]; then touch "$build_prefix/ready" fi export FFMPEG_DIR="$build_prefix/ffmpeg" -maturin build --release --locked --out unrepaired +maturin build -m av/Cargo.toml --release --locked --out unrepaired delocate-wheel --require-archs "$(uname -m)" -w dist unrepaired/*.whl python scripts/check_wheel_size.py dist/*.whl diff --git a/scripts/configure_oracle_ffmpeg.py b/scripts/configure_oracle_ffmpeg.py index f6df43b..e098748 100644 --- a/scripts/configure_oracle_ffmpeg.py +++ b/scripts/configure_oracle_ffmpeg.py @@ -5,10 +5,10 @@ import re from pathlib import Path -spec = importlib.util.find_spec("tensorcodec") -libs = Path(spec.origin).parent.parent / "tensorcodec.libs" +spec = importlib.util.find_spec("tensorcodec_av") +libs = Path(spec.origin).parent.parent / "tensorcodec_av.libs" if not libs.is_dir(): - raise RuntimeError("Expected an installed, repaired TensorCodec wheel") + raise RuntimeError("Expected an installed, repaired tensorcodec-av wheel") for library in libs.glob("lib*.so.*"): match = re.fullmatch(r"(lib[^-]+)-[0-9a-f]+(\.so\.\d+)", library.name) if match and match[1] in {"libavcodec", "libavformat", "libavutil", "libswscale", "libswresample"}: diff --git a/scripts/update_size_comparison.py b/scripts/update_size_comparison.py index 3250627..51debdc 100644 --- a/scripts/update_size_comparison.py +++ b/scripts/update_size_comparison.py @@ -118,7 +118,7 @@ def main() -> None: reference = policy["comparison"] pyav = policy["pyav"] snapshot = { - "tensorcodec": measure_release("tensorcodec", args.version, "cp310"), + "tensorcodec": measure_release("tensorcodec-av", args.version, "cp310"), "torchcodec": measure_release(reference["project"], reference["version"], reference["python_tag"]), "pyav": measure_release(pyav["project"], pyav["version"], pyav["python_tag"]), "torch": measure_release(**policy["torch"]), diff --git a/src/tensorcodec/__init__.py b/src/tensorcodec/__init__.py index 9b619cf..26f3a02 100644 --- a/src/tensorcodec/__init__.py +++ b/src/tensorcodec/__init__.py @@ -3,5 +3,5 @@ from tensorcodec import decoders, transforms from tensorcodec._frame import AudioSamples, Frame, FrameBatch -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = ["AudioSamples", "Frame", "FrameBatch", "decoders", "transforms"] diff --git a/src/tensorcodec/_opencv.py b/src/tensorcodec/_opencv.py index b20f34e..5a63b15 100644 --- a/src/tensorcodec/_opencv.py +++ b/src/tensorcodec/_opencv.py @@ -6,9 +6,10 @@ def opencv(): import cv2 except ImportError as exc: raise ImportError( - "Image codecs require OpenCV >= 4.13; install tensorcodec[images] " + "Image codecs require OpenCV >= 4.12; install tensorcodec[images] " "or use an existing compatible cv2 installation" ) from exc - if tuple(int(part) for part in cv2.__version__.split(".")[:2]) < (4, 13): - raise ImportError("Image codecs require OpenCV >= 4.13") + # 4.12: first PyPI wheels with GIF and AVIF decoders; 4.10/4.11 fail only those tests (docs/images.md). + if tuple(int(part) for part in cv2.__version__.split(".")[:2]) < (4, 12): + raise ImportError("Image codecs require OpenCV >= 4.12") return cv2 diff --git a/src/tensorcodec/decoders/_decoder.py b/src/tensorcodec/decoders/_decoder.py index ec19ad7..4c471cd 100644 --- a/src/tensorcodec/decoders/_decoder.py +++ b/src/tensorcodec/decoders/_decoder.py @@ -13,10 +13,25 @@ from tensorcodec._frame import AudioSamples, Frame, FrameBatch from tensorcodec._metadata import AudioStreamMetadata, VideoStreamMetadata -from tensorcodec._native import Decoder as NativeDecoder from tensorcodec.transforms import _pipeline +def _av_decoder(*args): + try: + from tensorcodec_av import _av + except ImportError as error: + raise ImportError( + "VideoDecoder and AudioDecoder require tensorcodec-av (the audio/video decoders), which could not be " + "imported. pip installs it automatically on Linux x86_64/aarch64 (glibc 2.17+) and macOS 14+ arm64 " + "with CPython; elsewhere tensorcodec provides only the image codecs." + ) from error + from tensorcodec import __version__ + + if _av.__version__ != __version__: + raise ImportError(f"tensorcodec {__version__} requires tensorcodec-av=={__version__}, found {_av.__version__}") + return _av.Decoder(*args) + + def _source(source): if isinstance(source, (str, os.PathLike)): return os.fspath(source) @@ -125,7 +140,7 @@ def __init__( self._seek_mode = seek_mode self._lock = RLock() self._closed = False - self._native = NativeDecoder(_source(source), "video", stream_index, int(num_ffmpeg_threads)) + self._native = _av_decoder(_source(source), "video", stream_index, int(num_ffmpeg_threads)) try: header = self._native.metadata(apply_rotation=output_format != "native") self.stream_index = header["stream_index"] @@ -424,7 +439,7 @@ def __init__(self, source, *, stream_index=None, sample_rate=None, num_channels= raise ValueError(f"{name} must be a positive integer") self._lock = RLock() self._closed = False - self._native = NativeDecoder(_source(source), "audio", stream_index) + self._native = _av_decoder(_source(source), "audio", stream_index) try: header = self._native.metadata() header.pop("time_base_num") diff --git a/src/tensorcodec/decoders/_images.py b/src/tensorcodec/decoders/_images.py index 328371d..2bef3b7 100644 --- a/src/tensorcodec/decoders/_images.py +++ b/src/tensorcodec/decoders/_images.py @@ -69,6 +69,8 @@ def _format(data): return "avif" if any(b in (b"heic", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"mif1", b"msf1") for b in brands): return "heic" + if data.startswith(b"BM"): + return "bmp" raise ValueError("Unsupported or unrecognized image format") @@ -94,6 +96,14 @@ def _png_channels(data): return channels +def _bmp_alpha(data): + """True for 32-bit BI_BITFIELDS with a nonzero alpha mask, the only BMP alpha Pillow also reads.""" + header = int.from_bytes(data[14:18], "little") + bits = int.from_bytes(data[28:30], "little") + compression = int.from_bytes(data[30:34], "little") + return bits == 32 and compression == 3 and header >= 56 and len(data) >= 70 and any(data[66:70]) + + def _jpeg_components(data): offset = 2 while offset < len(data): @@ -213,6 +223,9 @@ def _image(source, codec, mode, output_dtype): frame = cv.cvtColor(frame, cv.COLOR_BGR2RGB) elif frame.shape[-1] == 4: frame = cv.cvtColor(frame, cv.COLOR_BGRA2RGBA) + if codec == "bmp" and frame.shape[-1] == 4 and not _bmp_alpha(data): + # OpenCV keeps the padding byte of BI_BITFIELDS pixels without an alpha mask. + frame = frame[..., :3] if channels == 2 and frame.shape[-1] == 4: frame = frame[..., [0, 3]] if codec == "avif" and frame.dtype == np.uint16: @@ -258,7 +271,8 @@ def decode_image(source, *, mode="RGB", output_dtype=np.uint8): Sources are paths, bytes or 1-D uint8 arrays. Modes: UNCHANGED, GRAY, GRAY_ALPHA, RGB, RGB_ALPHA (case-insensitive strings or ImageReadMode). output_dtype is uint8, uint16 or 'auto'; integer conversion scales the range. - Requires optional OpenCV >= 4.13. HEIC and animated PNG are unsupported. + BMP is detected too. Requires optional OpenCV >= 4.12. HEIC and animated PNG + are unsupported. """ return _image(source, None, mode, output_dtype) diff --git a/tests/test_av_optional.py b/tests/test_av_optional.py new file mode 100644 index 0000000..0a9c0ed --- /dev/null +++ b/tests/test_av_optional.py @@ -0,0 +1,47 @@ +"""Image codecs work without tensorcodec-av; video/audio fail clearly when it is missing or skewed.""" + +import subprocess +import sys + + +def run_python(code): + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_image_codecs_without_tensorcodec_av(): + run_python( + """ +import sys +import numpy as np +from tensorcodec.decoders import * +from tensorcodec.encoders import PngEncoder +assert 'tensorcodec_av' not in sys.modules +sys.modules['tensorcodec_av'] = None +pixels = np.full((3, 4, 5), 91, np.uint8) +np.testing.assert_array_equal(decode_image(PngEncoder(pixels).to_tensor(), mode=ImageReadMode.RGB), pixels) +for decoder in (VideoDecoder, AudioDecoder): + try: + decoder(b'not media') + except ImportError as exc: + assert 'require tensorcodec-av' in str(exc), exc + else: + raise AssertionError(f'{decoder.__name__} worked without tensorcodec-av') +""" + ) + + +def test_version_skew_is_rejected(): + run_python( + """ +import sys +import types +sys.modules['tensorcodec_av'] = types.SimpleNamespace(_av=types.SimpleNamespace(__version__='0.0.0')) +from tensorcodec.decoders import VideoDecoder +try: + VideoDecoder(b'not media') +except ImportError as exc: + assert 'requires tensorcodec-av==' in str(exc) and 'found 0.0.0' in str(exc), exc +else: + raise AssertionError('mismatched tensorcodec-av was accepted') +""" + ) diff --git a/tests/test_images.py b/tests/test_images.py index d990d19..f9e8096 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -54,18 +54,10 @@ def encoded_images(tmp_path_factory): }.items(): run_ffmpeg("-i", root / "source.png", "-frames:v", 1, *options, "-threads", 1, root / f"image.{codec}") Image.fromarray(pixels).save(root / "image.webp", lossless=True) - (root / "second.png").write_bytes(png_bytes(255 - pixels)) - run_ffmpeg( - "-framerate", - 2, - "-pattern_type", - "glob", - "-i", - str(root / "s*.png"), - "-threads", - 1, - root / "animated.gif", - ) + # Numbered sequence: FFmpeg's glob pattern type is unavailable on Windows. + for index, frame in enumerate((pixels, 255 - pixels)): + (root / f"frame{index}.png").write_bytes(png_bytes(frame)) + run_ffmpeg("-framerate", 2, "-i", root / "frame%d.png", "-threads", 1, root / "animated.gif") return root @@ -125,6 +117,13 @@ def test_image_sources_and_content_detection(backend, tmp_path, kind): @pytest.mark.parametrize("codec", ["jpeg", "webp", "gif", "avif"]) def test_format_functions_and_dispatch(backend, encoded_images, codec): path = encoded_images / f"image.{codec}" + if codec == "avif" and not backend.__name__.startswith("torchcodec"): + import cv2 + + if not cv2.haveImageReader(str(path)): # e.g. opencv-python-headless 4.14 on Windows + with pytest.raises(RuntimeError, match="codec build support"): + backend.decode_avif(path) + return direct = as_numpy(getattr(backend, f"decode_{codec}")(path)) assert direct.shape == (3, 16, 24) assert direct.dtype == np.uint8 @@ -509,3 +508,51 @@ def test_low_bit_grayscale_transparency(bits): result = decode_png(data, mode=mode) np.testing.assert_array_equal(result[:-1], np.tile([[[gray, 0]]], (channels, 1, 1))) np.testing.assert_array_equal(result[-1], [[0, 255]]) + + +def bitfields_bmp(rgba, header_size, alpha_mask): + """32-bit BI_BITFIELDS BMP; header_size 40 stores RGB masks only, 124 is BITMAPV5HEADER.""" + height, width, _ = rgba.shape + pixels = rgba[::-1, :, [2, 1, 0, 3]].tobytes() + header = struct.pack("