diff --git a/.github/lux-ci-config.json b/.github/lux-ci-config.json index aec73f6..43a2257 100644 --- a/.github/lux-ci-config.json +++ b/.github/lux-ci-config.json @@ -1,5 +1,5 @@ { - "automation_ref": "v1.6", + "automation_ref": "903723cb3831e5721ce78e19b6ec53d061f0388f", "review": { "workflow_name": "luxie-tronic", "branches": [ diff --git a/.github/scripts/build-linux.sh b/.github/scripts/build-linux.sh index bc97b40..f569365 100755 --- a/.github/scripts/build-linux.sh +++ b/.github/scripts/build-linux.sh @@ -26,15 +26,30 @@ if [ ! -z "$GITHUB_ENV" ]; then echo "PATH=$PYBIN:$PATH" >> $GITHUB_ENV fi +# Install Rust for the Linux backend. +if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable +fi +if [ -f "$HOME/.cargo/env" ]; then + source "$HOME/.cargo/env" +fi +rustc --version +cargo --version + # install compile-time dependencies ${PYBIN}/pip install numpy==${NUMPY_VERSION} -${PYBIN}/pip install setuptools +${PYBIN}/pip install setuptools 'setuptools-rust>=1.10.2,<1.11.0' # List installed packages ${PYBIN}/pip freeze # Build pyvirtualcam wheel export LDFLAGS="-Wl,--strip-debug" +if [ "$PYTHON_VERSION" == "3.13" ]; then + # PyO3 0.21 officially supports up to Python 3.12; build cp313 via stable ABI. + export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 +fi ${PYBIN}/python setup.py bdist_wheel --dist-dir dist-tmp # Bundle external shared libraries into wheel and fix the wheel tags diff --git a/.github/scripts/build-macos.sh b/.github/scripts/build-macos.sh index f6997cd..43b0482 100755 --- a/.github/scripts/build-macos.sh +++ b/.github/scripts/build-macos.sh @@ -29,7 +29,7 @@ set -x popd # Install dependencies -pip install numpy==$NUMPY_VERSION wheel delocate setuptools +pip install numpy=="$NUMPY_VERSION" wheel delocate setuptools # List installed packages pip freeze diff --git a/.github/scripts/test-linux.sh b/.github/scripts/test-linux.sh index 9d34fb7..0934af2 100755 --- a/.github/scripts/test-linux.sh +++ b/.github/scripts/test-linux.sh @@ -27,18 +27,6 @@ ${PYBIN}/pip install ./dist/pyvirtualcam*cp${PYVER}*manylinux*.whl ${PYBIN}/pip install -r dev-requirements.txt -mkdir tmp_for_test -pushd tmp_for_test -# NOTE: TESTING DISABLED! -# The v4l2loopback kernel module cannot be installed as it depends -# on v4l2 (videodev) kernel support. -# videodev can either be built into the kernel directly or supported -# as loadable module. -# Azure (which is what GitHub Actions uses) only offers videodev -# in linux-modules-extra-azure starting from Ubuntu 20.10. -# See https://packages.ubuntu.com/search?suite=groovy&arch=any&mode=exactfilename&searchon=contents&keywords=videodev.ko -# However, GitHub Actions only uses LTS releases of Ubuntu and they -# are currently at 20.04 LTS. The next LTS will be 22.04 LTS -# which will come out in 2022. -#${PYBIN}/pytest -v -s /io/test -popd +# Device-dependent tests (test_camera.py, test_capture.py) require v4l2loopback +# and are not run in CI. Mock/utility tests validate the Python API contract. +${PYBIN}/pytest -v /io/test/test_backend_contract.py /io/test/test_util.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2bdf15..3d5c27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,109 +34,110 @@ jobs: # NOTE: When updating this list, also update the 'test' job! - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.8' numpy-version: '1.24.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.9' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.10' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.11' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.12' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.13' numpy-version: '2.2.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux2014_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.8' numpy-version: '1.24.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.9' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.10' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.11' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.12' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.13' numpy-version: '2.2.*' - - os-image: macos-13 + # GitHub retired macos-13 on 2025-12-04; use macos-15-intel for x86_64. + - os-image: macos-15-intel os-name: mac python-arch: x86_64 macos-min-version: '10.9' python-version: '3.8' numpy-version: '1.24.*' - - os-image: macos-13 + - os-image: macos-15-intel os-name: mac python-arch: x86_64 macos-min-version: '10.9' python-version: '3.9' numpy-version: '2.0.*' - - os-image: macos-13 + - os-image: macos-15-intel os-name: mac python-arch: x86_64 macos-min-version: '10.9' python-version: '3.10' numpy-version: '2.0.*' - - os-image: macos-13 + - os-image: macos-15-intel os-name: mac python-arch: x86_64 macos-min-version: '10.9' python-version: '3.11' numpy-version: '2.0.*' - - os-image: macos-13 + - os-image: macos-15-intel os-name: mac python-arch: x86_64 macos-min-version: '10.9' python-version: '3.12' numpy-version: '2.0.*' - - os-image: macos-13 + - os-image: macos-15-intel os-name: mac python-arch: x86_64 macos-min-version: '10.9' @@ -232,74 +233,74 @@ jobs: config: - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.8' numpy-version: '1.24.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.9' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.10' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.11' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.12' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux2014_x86_64 + docker-image: quay.io/pypa/manylinux2014_x86_64:2026.05.01-2 python-arch: 'x86_64' python-version: '3.13' numpy-version: '2.2.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux2014_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.8' numpy-version: '1.24.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.9' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.10' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.11' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.12' numpy-version: '2.0.*' - os-image: ubuntu-latest os-name: linux - docker-image: quay.io/pypa/manylinux_2_28_aarch64 + docker-image: quay.io/pypa/manylinux_2_28_aarch64:2026.05.01-2 python-arch: 'aarch64' python-version: '3.13' numpy-version: '2.2.*' diff --git a/.github/workflows/luxie-tronic.yml b/.github/workflows/luxie-tronic.yml index 19f2f6d..6b6c937 100644 --- a/.github/workflows/luxie-tronic.yml +++ b/.github/workflows/luxie-tronic.yml @@ -25,9 +25,15 @@ permissions: issues: write jobs: review: - uses: LuxTronic/luxie-workflows/.github/workflows/reusable-luxie-tronic.yml@v1.6 + uses: LuxTronic/luxie-workflows/.github/workflows/reusable-luxie-tronic.yml@903723cb3831e5721ce78e19b6ec53d061f0388f # v1.6 with: - automation_ref: v1.6 + automation_ref: 903723cb3831e5721ce78e19b6ec53d061f0388f policy_path: .codex/luxie-tronic-policy.json pr_number: ${{ github.event.inputs.pr_number || '' }} - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CODEX_BOT_PAT: ${{ secrets.CODEX_BOT_PAT }} + CODEX_BOT_GPG_PRIVATE_KEY: ${{ secrets.CODEX_BOT_GPG_PRIVATE_KEY }} + CODEX_BOT_GPG_PASSPHRASE: ${{ secrets.CODEX_BOT_GPG_PASSPHRASE }} + CODEX_BOT_GIT_NAME: ${{ secrets.CODEX_BOT_GIT_NAME }} + CODEX_BOT_GIT_EMAIL: ${{ secrets.CODEX_BOT_GIT_EMAIL }} diff --git a/.gitignore b/.gitignore index 15553e0..342acf8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,14 @@ build dist dist-docs *.pyd +*.so *.pyc *.zip *.exe .vscode tmp wheelhouse +target external/miniconda* *.png *.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0a0a5a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Agent Guidance + +This repository provides `pyvirtualcam`, a Python package with native virtual +camera backends. Keep the public Python API stable unless a change is explicitly +intended to be breaking. + +## Architecture + +- `pyvirtualcam/` contains the Python compatibility API. +- `crates/pyvirtualcam-core/` contains the Rust core and the Linux + `v4l2loopback` backend. +- `crates/pyvirtualcam-py/` contains the PyO3 module exported as + `pyvirtualcam._native_linux_v4l2loopback`. +- macOS and Windows still use the existing native C++/ObjC++ backends. + +## Development Notes + +- Preserve `pyvirtualcam.Camera`, `PixelFormat`, `Backend`, and + `register_backend` behavior. +- Keep pixel conversions backed by vendored `external/libyuv` unless parity is + proven with capture tests. +- Treat the hand-written V4L2 ABI definitions as a small, private surface. If + the Linux backend needs more V4L2 APIs, prefer generated or crate-backed + bindings. +- Run Rust checks with `cargo fmt --check`, `cargo clippy --workspace --locked + --all-targets -- -D warnings`, and `cargo test --workspace --locked`. +- Run Python checks with `python -m pytest -q test/test_backend_contract.py + test/test_util.py`; full capture tests need real virtual camera devices. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c97177..d49ffa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Rust workspace (`pyvirtualcam-core`, `pyvirtualcam-py`) with a native Rust camera API and PyO3 bindings. +- Mock backend contract tests (`test/test_backend_contract.py`) for CI without a virtual camera device. +- `AGENTS.md` and crate-level architecture documentation. + +### Changed +- Linux `v4l2loopback` backend is implemented in Rust; macOS and Windows backends remain C++/ObjC++. +- Linux CI installs Rust and runs mock Python tests on built wheels. + ## [0.14.0] - 2025-09-10 ### Added - macOS 14 / OBS 30+ support (#134). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c043954 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,354 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ndarray" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec170733ca37175f5d75a5bea5911d6ff45d2cd52849ce98b685394e4f2f37f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "pyvirtualcam-core" +version = "0.0.0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "pyvirtualcam-py" +version = "0.0.0" +dependencies = [ + "numpy", + "pyo3", + "pyvirtualcam-core", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9b75257 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = [ + "crates/pyvirtualcam-core", + "crates/pyvirtualcam-py", +] +resolver = "2" diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0298dba --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include Cargo.toml +recursive-include crates *.toml *.rs *.cpp +recursive-include external/libyuv/include *.h +recursive-include external/libyuv/source *.cc diff --git a/README.md b/README.md index 715cfa5..bd59446 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ with pyvirtualcam.Camera(width=1280, height=720, fps=20) as cam: pyvirtualcam uses the first available virtual camera it finds (see later section). -For more examples, including using different pixel formats like BGR, or selecting a specific camera device, check out the [`examples/`](https://github.com/letmaik/pyvirtualcam/tree/main/examples) folder. +For more examples, including using different pixel formats like BGR, selecting a specific camera device, sending to multiple Linux devices, or using the native Rust API, check out the [`examples/`](examples/) folder. -See also the [API Documentation](https://letmaik.github.io/pyvirtualcam). +See also the [API Documentation](docs/index.rst) (build with `sphinx-build -b html docs dist-docs`) and the shared [upstream docs](https://letmaik.github.io/pyvirtualcam). ## Installation @@ -60,14 +60,25 @@ This fork with Python 3.8 support provides wheels as GitHub Actions artifacts. - **Linux x86_64**: `wheel-linux-x86_64-3.X` - **macOS ARM64** (M1/M2/M3): `wheel-mac-arm64-3.X` (Python 3.10+ only) - **macOS x86_64**: `wheel-mac-x86_64-3.X` - - **Windows x86_64**: `wheel-windows-x86_64-3.X` 5. Extract the downloaded ZIP file 6. Install with: `pip install pyvirtualcam-*.whl` **Note:** Artifacts expire after 90 days. If you need wheels for production, consider building from source or setting up your own artifact storage. +**Windows note:** The Windows backend sources and build scripts are still present, but this fork's active GitHub Actions workflow currently publishes Linux and macOS wheels only. + pyvirtualcam relies on existing virtual cameras which have to be installed first. See the next section for details. +## Rust backend status + +The Linux `v4l2loopback` backend is implemented in Rust and exposed through the existing Python API. The Rust core crate also provides an initial native Rust API for Linux consumers. Windows and macOS still use the existing native backends. + +Native Rust example: + +```sh +cargo run -p pyvirtualcam-core --example simple +``` + ## Supported virtual cameras ### Windows: OBS @@ -123,10 +134,12 @@ cd pyvirtualcam pip install . ``` +Linux source builds require a Rust toolchain because the `v4l2loopback` backend is implemented in Rust. Install Rust with [rustup](https://rustup.rs/) before running `pip install .`. + ### Windows These instructions are experimental and support is not provided for them. -Typically, there should be no need to build manually since wheels are hosted on PyPI. +Typically, there should be no need to build manually since wheels are published by CI for supported platforms. You need to have Visual Studio installed to build pyvirtualcam. diff --git a/crates/pyvirtualcam-core/Cargo.toml b/crates/pyvirtualcam-core/Cargo.toml new file mode 100644 index 0000000..0bceb53 --- /dev/null +++ b/crates/pyvirtualcam-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pyvirtualcam-core" +version = "0.0.0" +edition = "2021" +license = "GPL-2.0-only" +build = "build.rs" +readme = "README.md" + +[features] +default = ["linux"] +linux = [] + +[dependencies] +libc = "0.2" + +[build-dependencies] +cc = "1" diff --git a/crates/pyvirtualcam-core/README.md b/crates/pyvirtualcam-core/README.md new file mode 100644 index 0000000..7fc5fcb --- /dev/null +++ b/crates/pyvirtualcam-core/README.md @@ -0,0 +1,65 @@ +# pyvirtualcam-core + +`pyvirtualcam-core` is the Rust implementation layer for pyvirtualcam. +It is designed to be used in two ways: + +- as the backend for the existing Python package through the `pyvirtualcam-py` + PyO3 extension crate +- as the foundation for a native Rust API + +The Python package remains the compatibility surface for existing users. The +Rust core owns platform backend logic, pixel format handling, and conversion +wrappers as those pieces are migrated. + +## Architecture + +The migration is intentionally incremental: + +```text +Python users + -> pyvirtualcam.Camera + -> pyvirtualcam-py PyO3 extension + -> pyvirtualcam-core + -> platform backend + +Rust users + -> pyvirtualcam-core CameraBuilder + -> platform backend +``` + +Linux `v4l2loopback` is the first Rust backend. macOS and Windows still use the +existing native implementations until they can be ported and tested without +changing Python behavior. + +## Pixel Conversion + +This crate continues to build the vendored `external/libyuv` source instead of +reimplementing pixel conversion in Rust. + +That choice is deliberate: + +- existing pyvirtualcam behavior already depends on libyuv conversion details +- capture tests compare pixel output with tight tolerances +- using the same vendored source avoids system-library drift across wheel builds + +The Rust code talks to libyuv through a small C ABI wrapper in +`cpp/libyuv_wrapper.cpp`. More conversions can be added there as additional +platform backends move into Rust. + +## V4L2 ABI + +The Linux backend uses a small hand-written subset of the V4L2 ABI rather than +bindgen-generated bindings. This avoids adding a libclang dependency to wheel +builds and keeps the reviewed surface small. + +The ABI definitions are intentionally limited to the calls used by this backend: + +- `VIDIOC_QUERYCAP` +- `VIDIOC_S_FMT` +- `struct v4l2_capability` +- `struct v4l2_format` +- `struct v4l2_pix_format` + +Unit tests assert the relevant struct sizes and ioctl request numbers against +Linux header values. If the backend grows beyond this small surface, generated +bindings or a maintained V4L2 crate should be reconsidered. diff --git a/crates/pyvirtualcam-core/build.rs b/crates/pyvirtualcam-core/build.rs new file mode 100644 index 0000000..82679fc --- /dev/null +++ b/crates/pyvirtualcam-core/build.rs @@ -0,0 +1,47 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.join("../.."); + let libyuv_source_dir = repo_root.join("external/libyuv/source"); + let libyuv_include_dir = repo_root.join("external/libyuv/include"); + + if !libyuv_source_dir.exists() { + panic!("external/libyuv/source not found; initialize the libyuv submodule"); + } + + let mut build = cc::Build::new(); + build + .cpp(true) + .include(&libyuv_include_dir) + .file(manifest_dir.join("cpp/libyuv_wrapper.cpp")) + .flag_if_supported("-std=c++17"); + + if env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("aarch64") { + // GB8's GCC/binutils reject some libyuv AArch64 NEON inline assembly. + // Prefer portable C fallbacks over failing to build the backend. + build.define("LIBYUV_DISABLE_NEON", None); + } + + let mut sources: Vec<_> = fs::read_dir(&libyuv_source_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "cc")) + .collect(); + sources.sort(); + + for source in sources { + build.file(source); + } + + build.compile("pyvirtualcam_libyuv"); + + println!("cargo:rerun-if-changed={}", libyuv_source_dir.display()); + println!("cargo:rerun-if-changed={}", libyuv_include_dir.display()); + println!( + "cargo:rerun-if-changed={}", + manifest_dir.join("cpp/libyuv_wrapper.cpp").display() + ); +} diff --git a/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp b/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp new file mode 100644 index 0000000..d87ada4 --- /dev/null +++ b/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp @@ -0,0 +1,44 @@ +#include +#include + +#include + +extern "C" uint32_t pyvc_canonical_fourcc(uint32_t fourcc) { + return libyuv::CanonicalFourCC(fourcc); +} + +extern "C" void pyvc_rgb_to_i420( + const uint8_t* rgb, + uint8_t* i420, + int32_t width, + int32_t height +) { + int32_t height_abs = std::abs(height); + int32_t chroma_width = (width + 1) / 2; + int32_t chroma_height = (height_abs + 1) / 2; + + libyuv::RAWToI420( + rgb, width * 3, + i420, width, + i420 + width * height_abs, chroma_width, + i420 + width * height_abs + chroma_width * chroma_height, chroma_width, + width, height); +} + +extern "C" void pyvc_bgr_to_i420( + const uint8_t* bgr, + uint8_t* i420, + int32_t width, + int32_t height +) { + int32_t height_abs = std::abs(height); + int32_t chroma_width = (width + 1) / 2; + int32_t chroma_height = (height_abs + 1) / 2; + + libyuv::RGB24ToI420( + bgr, width * 3, + i420, width, + i420 + width * height_abs, chroma_width, + i420 + width * height_abs + chroma_width * chroma_height, chroma_width, + width, height); +} diff --git a/crates/pyvirtualcam-core/examples/simple.rs b/crates/pyvirtualcam-core/examples/simple.rs new file mode 100644 index 0000000..40e1655 --- /dev/null +++ b/crates/pyvirtualcam-core/examples/simple.rs @@ -0,0 +1,32 @@ +use std::time::Duration; + +use pyvirtualcam_core::{CameraBuilder, PixelFormat}; + +fn main() -> pyvirtualcam_core::Result<()> { + let width = 640; + let height = 480; + let fps = 20.0; + let mut camera = CameraBuilder::new(width, height, fps) + .format(PixelFormat::Rgb) + .build()?; + + println!( + "Using virtual camera: {} ({:?})", + camera.device(), + camera.native_format() + ); + + let mut frame = vec![0; PixelFormat::Rgb.frame_size(width, height)]; + for frame_idx in 0..100 { + let color = (frame_idx % 255) as u8; + for pixel in frame.chunks_exact_mut(3) { + pixel.copy_from_slice(&[color, 255 - color, color / 2]); + } + + camera.send(&frame)?; + std::thread::sleep(Duration::from_secs_f64(1.0 / fps)); + } + + camera.close(); + Ok(()) +} diff --git a/crates/pyvirtualcam-core/src/camera.rs b/crates/pyvirtualcam-core/src/camera.rs new file mode 100644 index 0000000..bc5ffd6 --- /dev/null +++ b/crates/pyvirtualcam-core/src/camera.rs @@ -0,0 +1,126 @@ +use crate::{PixelFormat, Result}; + +#[cfg(target_os = "linux")] +use crate::linux::V4l2LoopbackCamera; + +#[derive(Clone, Debug)] +pub struct CameraBuilder { + width: u32, + height: u32, + fps: f64, + format: PixelFormat, + devices: Option>, +} + +impl CameraBuilder { + pub fn new(width: u32, height: u32, fps: f64) -> Self { + Self { + width, + height, + fps, + format: PixelFormat::Rgb, + devices: None, + } + } + + pub fn format(mut self, format: PixelFormat) -> Self { + self.format = format; + self + } + + pub fn device(mut self, device: impl Into) -> Self { + self.devices = Some(vec![device.into()]); + self + } + + pub fn devices(mut self, devices: impl IntoIterator>) -> Self { + self.devices = Some(devices.into_iter().map(Into::into).collect()); + self + } + + pub fn build(self) -> Result { + let _fps = self.fps; + + #[cfg(target_os = "linux")] + { + let backend = V4l2LoopbackCamera::new( + self.width, + self.height, + self.format.fourcc(), + self.devices, + )?; + Ok(Camera::V4l2Loopback(backend)) + } + + #[cfg(not(target_os = "linux"))] + { + Err(crate::Error::runtime( + "No Rust virtual camera backend is available on this platform yet.", + )) + } + } +} + +pub enum Camera { + #[cfg(target_os = "linux")] + V4l2Loopback(V4l2LoopbackCamera), +} + +impl Camera { + pub fn send(&mut self, frame: &[u8]) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::V4l2Loopback(backend) => backend.send(frame), + } + } + + pub fn close(&mut self) { + match self { + #[cfg(target_os = "linux")] + Self::V4l2Loopback(backend) => backend.stop(), + } + } + + pub fn device(&self) -> String { + match self { + #[cfg(target_os = "linux")] + Self::V4l2Loopback(backend) => backend.device(), + } + } + + pub fn native_format(&self) -> PixelFormat { + match self { + #[cfg(target_os = "linux")] + Self::V4l2Loopback(backend) => backend.native_format(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builder_defaults_to_rgb_and_auto_device() { + let builder = CameraBuilder::new(4, 2, 30.0); + + assert_eq!(builder.width, 4); + assert_eq!(builder.height, 2); + assert_eq!(builder.fps, 30.0); + assert_eq!(builder.format, PixelFormat::Rgb); + assert!(builder.devices.is_none()); + } + + #[test] + fn builder_accepts_explicit_devices() { + let builder = CameraBuilder::new(4, 2, 30.0) + .format(PixelFormat::Bgr) + .devices(["/dev/video0", "/dev/video1"]); + + assert_eq!(builder.format, PixelFormat::Bgr); + assert_eq!( + builder.devices.unwrap(), + vec!["/dev/video0".to_owned(), "/dev/video1".to_owned()] + ); + } +} diff --git a/crates/pyvirtualcam-core/src/convert.rs b/crates/pyvirtualcam-core/src/convert.rs new file mode 100644 index 0000000..d1619a9 --- /dev/null +++ b/crates/pyvirtualcam-core/src/convert.rs @@ -0,0 +1,98 @@ +use crate::error::{Error, Result}; +use crate::fourcc::FourCc; +use crate::PixelFormat; + +extern "C" { + fn pyvc_canonical_fourcc(fourcc: FourCc) -> FourCc; + fn pyvc_rgb_to_i420(rgb: *const u8, i420: *mut u8, width: i32, height: i32); + fn pyvc_bgr_to_i420(bgr: *const u8, i420: *mut u8, width: i32, height: i32); +} + +pub fn canonical_fourcc(fourcc: FourCc) -> FourCc { + unsafe { pyvc_canonical_fourcc(fourcc) } +} + +pub fn rgb_to_i420(input: &[u8], output: &mut [u8], width: u32, height: u32) -> Result<()> { + convert_to_i420(PixelFormat::Rgb, input, output, width, height) +} + +pub fn bgr_to_i420(input: &[u8], output: &mut [u8], width: u32, height: u32) -> Result<()> { + convert_to_i420(PixelFormat::Bgr, input, output, width, height) +} + +fn convert_to_i420( + input_format: PixelFormat, + input: &[u8], + output: &mut [u8], + width: u32, + height: u32, +) -> Result<()> { + if width > i32::MAX as u32 || height > i32::MAX as u32 { + return Err(Error::invalid_argument( + "width and height must fit in i32 for libyuv conversion", + )); + } + + let expected_input = input_format.frame_size(width, height); + let expected_output = PixelFormat::I420.frame_size(width, height); + if input.len() < expected_input { + return Err(Error::invalid_argument( + "input frame is smaller than expected", + )); + } + if output.len() < expected_output { + return Err(Error::invalid_argument( + "output frame is smaller than expected", + )); + } + + let width = width as i32; + let height = height as i32; + + unsafe { + match input_format { + PixelFormat::Rgb => { + pyvc_rgb_to_i420(input.as_ptr(), output.as_mut_ptr(), width, height); + } + PixelFormat::Bgr => { + pyvc_bgr_to_i420(input.as_ptr(), output.as_mut_ptr(), width, height); + } + _ => unreachable!("only RGB and BGR convert to I420 here"), + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_short_input_buffer() { + let mut output = vec![0; PixelFormat::I420.frame_size(4, 2)]; + + let err = rgb_to_i420(&[0; 1], &mut output, 4, 2).unwrap_err(); + + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn rejects_short_output_buffer() { + let input = vec![0; PixelFormat::Rgb.frame_size(4, 2)]; + + let err = rgb_to_i420(&input, &mut [0; 1], 4, 2).unwrap_err(); + + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn converts_rgb_to_i420_with_expected_size() { + let input = vec![128; PixelFormat::Rgb.frame_size(4, 2)]; + let mut output = vec![0; PixelFormat::I420.frame_size(4, 2)]; + + rgb_to_i420(&input, &mut output, 4, 2).unwrap(); + + assert_eq!(output.len(), 12); + } +} diff --git a/crates/pyvirtualcam-core/src/error.rs b/crates/pyvirtualcam-core/src/error.rs new file mode 100644 index 0000000..db91fd8 --- /dev/null +++ b/crates/pyvirtualcam-core/src/error.rs @@ -0,0 +1,50 @@ +use std::fmt; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + InvalidArgument(String), + Runtime(String), + Io { + context: String, + source: std::io::Error, + }, +} + +impl Error { + pub fn invalid_argument(message: impl Into) -> Self { + Self::InvalidArgument(message.into()) + } + + pub fn runtime(message: impl Into) -> Self { + Self::Runtime(message.into()) + } + + pub fn io(context: impl Into) -> Self { + Self::Io { + context: context.into(), + source: std::io::Error::last_os_error(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidArgument(message) | Self::Runtime(message) => f.write_str(message), + Self::Io { context, source } => { + write!(f, "{context}: {source}") + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} diff --git a/crates/pyvirtualcam-core/src/formats.rs b/crates/pyvirtualcam-core/src/formats.rs new file mode 100644 index 0000000..c18420f --- /dev/null +++ b/crates/pyvirtualcam-core/src/formats.rs @@ -0,0 +1,103 @@ +use crate::error::{Error, Result}; +use crate::fourcc::{ + FourCc, FOURCC_24BG, FOURCC_ABGR, FOURCC_I420, FOURCC_J400, FOURCC_NV12, FOURCC_RAW, + FOURCC_UYVY, FOURCC_YUY2, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PixelFormat { + Rgb, + Bgr, + Rgba, + Gray, + I420, + Nv12, + Yuyv, + Uyvy, +} + +impl PixelFormat { + pub fn from_canonical_fourcc(fourcc: FourCc) -> Result { + match fourcc { + FOURCC_RAW => Ok(Self::Rgb), + FOURCC_24BG => Ok(Self::Bgr), + FOURCC_ABGR => Ok(Self::Rgba), + FOURCC_J400 => Ok(Self::Gray), + FOURCC_I420 => Ok(Self::I420), + FOURCC_NV12 => Ok(Self::Nv12), + FOURCC_YUY2 => Ok(Self::Yuyv), + FOURCC_UYVY => Ok(Self::Uyvy), + _ => Err(Error::runtime("Unsupported image format.")), + } + } + + pub fn fourcc(self) -> FourCc { + match self { + Self::Rgb => FOURCC_RAW, + Self::Bgr => FOURCC_24BG, + Self::Rgba => FOURCC_ABGR, + Self::Gray => FOURCC_J400, + Self::I420 => FOURCC_I420, + Self::Nv12 => FOURCC_NV12, + Self::Yuyv => FOURCC_YUY2, + Self::Uyvy => FOURCC_UYVY, + } + } + + pub fn frame_size(self, width: u32, height: u32) -> usize { + let width = width as usize; + let height = height as usize; + let pixels = width.saturating_mul(height); + match self { + Self::Rgb | Self::Bgr => pixels.saturating_mul(3), + Self::Rgba => pixels.saturating_mul(4), + Self::Gray => pixels, + Self::I420 | Self::Nv12 => { + let chroma_w = width.div_ceil(2); + let chroma_h = height.div_ceil(2); + pixels.saturating_add(2 * chroma_w.saturating_mul(chroma_h)) + } + Self::Yuyv | Self::Uyvy => pixels.saturating_mul(2), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calculates_frame_sizes() { + assert_eq!(PixelFormat::Rgb.frame_size(4, 2), 24); + assert_eq!(PixelFormat::Bgr.frame_size(4, 2), 24); + assert_eq!(PixelFormat::Rgba.frame_size(4, 2), 32); + assert_eq!(PixelFormat::Gray.frame_size(4, 2), 8); + assert_eq!(PixelFormat::I420.frame_size(4, 2), 12); + assert_eq!(PixelFormat::Nv12.frame_size(4, 2), 12); + assert_eq!(PixelFormat::I420.frame_size(5, 3), 27); + assert_eq!(PixelFormat::Nv12.frame_size(5, 3), 27); + assert_eq!(PixelFormat::Yuyv.frame_size(4, 2), 16); + assert_eq!(PixelFormat::Uyvy.frame_size(4, 2), 16); + } + + #[test] + fn maps_supported_fourcc_values() { + let formats = [ + PixelFormat::Rgb, + PixelFormat::Bgr, + PixelFormat::Rgba, + PixelFormat::Gray, + PixelFormat::I420, + PixelFormat::Nv12, + PixelFormat::Yuyv, + PixelFormat::Uyvy, + ]; + + for format in formats { + assert_eq!( + PixelFormat::from_canonical_fourcc(format.fourcc()).unwrap(), + format + ); + } + } +} diff --git a/crates/pyvirtualcam-core/src/fourcc.rs b/crates/pyvirtualcam-core/src/fourcc.rs new file mode 100644 index 0000000..b416cca --- /dev/null +++ b/crates/pyvirtualcam-core/src/fourcc.rs @@ -0,0 +1,38 @@ +pub type FourCc = u32; + +pub const fn encode_fourcc(bytes: &[u8; 4]) -> FourCc { + bytes[0] as FourCc + | ((bytes[1] as FourCc) << 8) + | ((bytes[2] as FourCc) << 16) + | ((bytes[3] as FourCc) << 24) +} + +pub const FOURCC_RAW: FourCc = encode_fourcc(b"raw "); +pub const FOURCC_24BG: FourCc = encode_fourcc(b"24BG"); +pub const FOURCC_ABGR: FourCc = encode_fourcc(b"ABGR"); +pub const FOURCC_J400: FourCc = encode_fourcc(b"J400"); +pub const FOURCC_I420: FourCc = encode_fourcc(b"I420"); +pub const FOURCC_NV12: FourCc = encode_fourcc(b"NV12"); +pub const FOURCC_YUY2: FourCc = encode_fourcc(b"YUY2"); +pub const FOURCC_UYVY: FourCc = encode_fourcc(b"UYVY"); + +pub const V4L2_PIX_FMT_GREY: FourCc = encode_fourcc(b"GREY"); +pub const V4L2_PIX_FMT_NV12: FourCc = FOURCC_NV12; +pub const V4L2_PIX_FMT_UYVY: FourCc = FOURCC_UYVY; +pub const V4L2_PIX_FMT_YUYV: FourCc = encode_fourcc(b"YUYV"); +pub const V4L2_PIX_FMT_YUV420: FourCc = encode_fourcc(b"YU12"); + +pub fn canonical_fourcc(fourcc: FourCc) -> FourCc { + crate::convert::canonical_fourcc(fourcc) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_fourcc_like_python() { + assert_eq!(encode_fourcc(b"I420"), 0x30323449); + assert_eq!(encode_fourcc(b"raw "), 0x20776172); + } +} diff --git a/crates/pyvirtualcam-core/src/lib.rs b/crates/pyvirtualcam-core/src/lib.rs new file mode 100644 index 0000000..8d8abf4 --- /dev/null +++ b/crates/pyvirtualcam-core/src/lib.rs @@ -0,0 +1,19 @@ +//! Rust core for pyvirtualcam. +//! +//! The crate owns backend implementations and shared format/conversion logic +//! while the Python package remains the compatibility API for existing users. +//! Linux `v4l2loopback` is the first Rust backend; other platforms continue to +//! use the existing native code until they can be ported and tested. + +pub mod camera; +pub mod convert; +pub mod error; +pub mod formats; +pub mod fourcc; + +#[cfg(target_os = "linux")] +pub mod linux; + +pub use camera::{Camera, CameraBuilder}; +pub use error::{Error, Result}; +pub use formats::PixelFormat; diff --git a/crates/pyvirtualcam-core/src/linux.rs b/crates/pyvirtualcam-core/src/linux.rs new file mode 100644 index 0000000..2e0308e --- /dev/null +++ b/crates/pyvirtualcam-core/src/linux.rs @@ -0,0 +1,499 @@ +//! Linux `v4l2loopback` backend. +//! +//! This module uses a deliberately small hand-written V4L2 ABI surface. The +//! project still builds binary wheels, so avoiding bindgen keeps libclang out of +//! the build requirements. The definitions below are covered by tests for the +//! struct sizes and ioctl request numbers used by this backend. + +use std::collections::HashSet; +use std::mem; +use std::os::fd::RawFd; +use std::sync::{Mutex, OnceLock}; + +use crate::convert; +use crate::error::{Error, Result}; +use crate::formats::PixelFormat; +use crate::fourcc::{ + canonical_fourcc, FourCc, V4L2_PIX_FMT_GREY, V4L2_PIX_FMT_NV12, V4L2_PIX_FMT_UYVY, + V4L2_PIX_FMT_YUV420, V4L2_PIX_FMT_YUYV, +}; + +const V4L2_CAP_VIDEO_OUTPUT: u32 = 0x0000_0002; +const V4L2_BUF_TYPE_VIDEO_OUTPUT: u32 = 2; +const V4L2_LOOPBACK_DRIVER: &[u8] = b"v4l2 loopback"; +const IOC_WRITE: u32 = 1; +const IOC_READ: u32 = 2; + +static ACTIVE_DEVICES: OnceLock>> = OnceLock::new(); + +fn active_devices() -> &'static Mutex> { + ACTIVE_DEVICES.get_or_init(|| Mutex::new(HashSet::new())) +} + +pub struct V4l2LoopbackCamera { + running: bool, + camera_fds: Vec, + camera_devices: Vec, + frame_format: PixelFormat, + native_format: PixelFormat, + frame_width: u32, + frame_height: u32, + out_frame_size: usize, + buffer_output: Vec, +} + +impl V4l2LoopbackCamera { + pub fn new( + width: u32, + height: u32, + fourcc: FourCc, + devices: Option>, + ) -> Result { + let frame_fourcc = canonical_fourcc(fourcc); + let frame_format = PixelFormat::from_canonical_fourcc(frame_fourcc)?; + + let (native_format, out_frame_fmt_v4l) = match frame_format { + PixelFormat::Rgb | PixelFormat::Bgr => (PixelFormat::I420, V4L2_PIX_FMT_YUV420), + PixelFormat::Gray => (PixelFormat::Gray, V4L2_PIX_FMT_GREY), + PixelFormat::I420 => (PixelFormat::I420, V4L2_PIX_FMT_YUV420), + PixelFormat::Nv12 => (PixelFormat::Nv12, V4L2_PIX_FMT_NV12), + PixelFormat::Yuyv => (PixelFormat::Yuyv, V4L2_PIX_FMT_YUYV), + PixelFormat::Uyvy => (PixelFormat::Uyvy, V4L2_PIX_FMT_UYVY), + PixelFormat::Rgba => return Err(Error::runtime("Unsupported image format.")), + }; + + let out_frame_size = native_format.frame_size(width, height); + let mut buffer_output = Vec::new(); + if matches!(frame_format, PixelFormat::Rgb | PixelFormat::Bgr) { + buffer_output.resize(out_frame_size, 0); + } + + let auto_detect = devices.is_none(); + let device_names = if let Some(devices) = devices { + if devices.is_empty() { + return Err(Error::invalid_argument("Device list cannot be empty.")); + } + devices + } else { + discover_devices()? + }; + + let mut camera_fds = Vec::new(); + let mut camera_devices = Vec::new(); + let mut opened_device = false; + + for device_name in device_names { + let camera_fd = match try_open(&device_name) { + Ok(fd) => fd, + Err(err) if auto_detect && matches!(err, Error::InvalidArgument(_)) => continue, + Err(err) => { + cleanup_open_devices(&camera_fds, &camera_devices); + return Err(err); + } + }; + + if let Err(err) = + configure_device(camera_fd, &device_name, width, height, out_frame_fmt_v4l) + { + unsafe { + libc::close(camera_fd); + } + release_device(&device_name); + cleanup_open_devices(&camera_fds, &camera_devices); + return Err(err); + } + + camera_fds.push(camera_fd); + camera_devices.push(device_name); + opened_device = true; + + if auto_detect { + break; + } + } + + if !opened_device { + if auto_detect { + return Err(Error::runtime( + "All v4l2 loopback devices at /dev/video[0-99] are busy. Is another process using them?", + )); + } + return Err(Error::runtime( + "Failed to open any of the requested devices.", + )); + } + + Ok(Self { + running: true, + camera_fds, + camera_devices, + frame_format, + native_format, + frame_width: width, + frame_height: height, + out_frame_size, + buffer_output, + }) + } + + pub fn stop(&mut self) { + if !self.running { + return; + } + + cleanup_open_devices(&self.camera_fds, &self.camera_devices); + self.running = false; + } + + pub fn send(&mut self, frame: &[u8]) -> Result<()> { + if !self.running { + return Ok(()); + } + + let expected_frame_size = self + .frame_format + .frame_size(self.frame_width, self.frame_height); + if frame.len() < expected_frame_size { + return Err(Error::invalid_argument( + "input frame is smaller than expected", + )); + } + + let out_frame = match self.frame_format { + PixelFormat::Rgb => { + convert::rgb_to_i420( + frame, + &mut self.buffer_output, + self.frame_width, + self.frame_height, + )?; + self.buffer_output.as_slice() + } + PixelFormat::Bgr => { + convert::bgr_to_i420( + frame, + &mut self.buffer_output, + self.frame_width, + self.frame_height, + )?; + self.buffer_output.as_slice() + } + PixelFormat::Gray + | PixelFormat::I420 + | PixelFormat::Nv12 + | PixelFormat::Yuyv + | PixelFormat::Uyvy => frame, + PixelFormat::Rgba => return Err(Error::runtime("Unsupported image format.")), + }; + + for (fd, device) in self.camera_fds.iter().zip(&self.camera_devices) { + let written = + unsafe { libc::write(*fd, out_frame.as_ptr().cast(), self.out_frame_size) }; + if written == -1 { + eprintln!( + "error writing frame to {}: {}", + device, + std::io::Error::last_os_error() + ); + } + } + + Ok(()) + } + + pub fn device(&self) -> String { + self.camera_devices.join(", ") + } + + pub fn native_fourcc(&self) -> FourCc { + self.native_format.fourcc() + } + + pub fn native_format(&self) -> PixelFormat { + self.native_format + } +} + +impl Drop for V4l2LoopbackCamera { + fn drop(&mut self) { + self.stop(); + } +} + +fn discover_devices() -> Result> { + let mut device_names = Vec::new(); + + for i in 0..100 { + let device_name = format!("/dev/video{i}"); + let c_device_name = c_string(&device_name)?; + let fd = unsafe { libc::open(c_device_name.as_ptr(), libc::O_WRONLY | libc::O_SYNC) }; + if fd == -1 { + continue; + } + + let is_valid = validate_fd(fd, &device_name).is_ok(); + unsafe { + libc::close(fd); + } + + if is_valid { + device_names.push(device_name); + } + } + + if device_names.is_empty() { + return Err(Error::runtime( + "No v4l2 loopback device found at /dev/video[0-99]. Did you run 'modprobe v4l2loopback'? See also pyvirtualcam's documentation.", + )); + } + + Ok(device_names) +} + +fn try_open(device_name: &str) -> Result { + { + let mut active = active_devices().lock().unwrap(); + if active.contains(device_name) { + return Err(Error::invalid_argument(format!( + "Device {device_name} is already in use." + ))); + } + active.insert(device_name.to_string()); + } + + let release_on_error = |device_name: &str| { + release_device(device_name); + }; + + let c_device_name = match c_string(device_name) { + Ok(value) => value, + Err(err) => { + release_on_error(device_name); + return Err(err); + } + }; + let camera_fd = unsafe { libc::open(c_device_name.as_ptr(), libc::O_WRONLY | libc::O_SYNC) }; + if camera_fd == -1 { + release_on_error(device_name); + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EACCES) => { + return Err(Error::runtime(format!( + "Could not access {device_name} due to missing permissions. Did you add your user to the 'video' group? Run 'usermod -a -G video myusername' and log out and in again." + ))); + } + Some(libc::ENOENT) => { + return Err(Error::invalid_argument(format!( + "Device {device_name} does not exist." + ))); + } + _ => { + return Err(Error::invalid_argument(format!( + "Device {device_name} could not be opened: {err}" + ))); + } + } + } + + if let Err(err) = validate_fd(camera_fd, device_name) { + unsafe { + libc::close(camera_fd); + } + release_on_error(device_name); + return Err(err); + } + + Ok(camera_fd) +} + +fn release_device(device_name: &str) { + active_devices().lock().unwrap().remove(device_name); +} + +fn validate_fd(camera_fd: RawFd, device_name: &str) -> Result<()> { + let mut capability = V4l2Capability::default(); + let result = unsafe { libc::ioctl(camera_fd, vidioc_querycap(), &mut capability) }; + if result == -1 { + return Err(Error::invalid_argument(format!( + "Device capabilities of {device_name} could not be queried." + ))); + } + + if capability.capabilities & V4L2_CAP_VIDEO_OUTPUT == 0 { + return Err(Error::invalid_argument(format!( + "Device {device_name} is not a video output device." + ))); + } + + let driver = nul_terminated_bytes(&capability.driver); + if driver != V4L2_LOOPBACK_DRIVER { + return Err(Error::invalid_argument(format!( + "Device {device_name} is not a V4L2 device." + ))); + } + + Ok(()) +} + +fn configure_device( + camera_fd: RawFd, + device_name: &str, + width: u32, + height: u32, + pixelformat: FourCc, +) -> Result<()> { + let pix = V4l2PixFormat { + width, + height, + pixelformat, + ..Default::default() + }; + let mut format = V4l2Format { + type_: V4L2_BUF_TYPE_VIDEO_OUTPUT, + fmt: V4l2FormatUnion { pix }, + }; + + let result = unsafe { libc::ioctl(camera_fd, vidioc_s_fmt(), &mut format) }; + if result == -1 { + return Err(Error::runtime(format!( + "Virtual camera device {device_name} could not be configured: {}", + std::io::Error::last_os_error() + ))); + } + + Ok(()) +} + +fn cleanup_open_devices(camera_fds: &[RawFd], camera_devices: &[String]) { + for fd in camera_fds { + unsafe { + libc::close(*fd); + } + } + + let mut active = active_devices().lock().unwrap(); + for device in camera_devices { + active.remove(device); + } +} + +fn c_string(value: &str) -> Result { + std::ffi::CString::new(value) + .map_err(|_| Error::invalid_argument("device name must not contain NUL bytes")) +} + +fn nul_terminated_bytes(bytes: &[u8]) -> &[u8] { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + &bytes[..end] +} + +fn vidioc_querycap() -> libc::c_ulong { + ior::(b'V', 0) +} + +fn vidioc_s_fmt() -> libc::c_ulong { + iowr::(b'V', 5) +} + +fn ior(type_: u8, nr: u8) -> libc::c_ulong { + ioc(IOC_READ, type_, nr, mem::size_of::()) +} + +fn iowr(type_: u8, nr: u8) -> libc::c_ulong { + ioc(IOC_READ | IOC_WRITE, type_, nr, mem::size_of::()) +} + +fn ioc(dir: u32, type_: u8, nr: u8, size: usize) -> libc::c_ulong { + const IOC_NRBITS: u32 = 8; + const IOC_TYPEBITS: u32 = 8; + const IOC_SIZEBITS: u32 = 14; + + const IOC_NRSHIFT: u32 = 0; + const IOC_TYPESHIFT: u32 = IOC_NRSHIFT + IOC_NRBITS; + const IOC_SIZESHIFT: u32 = IOC_TYPESHIFT + IOC_TYPEBITS; + const IOC_DIRSHIFT: u32 = IOC_SIZESHIFT + IOC_SIZEBITS; + + ((dir as libc::c_ulong) << IOC_DIRSHIFT) + | ((type_ as libc::c_ulong) << IOC_TYPESHIFT) + | ((nr as libc::c_ulong) << IOC_NRSHIFT) + | ((size as libc::c_ulong) << IOC_SIZESHIFT) +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct V4l2Capability { + driver: [u8; 16], + card: [u8; 32], + bus_info: [u8; 32], + version: u32, + capabilities: u32, + device_caps: u32, + reserved: [u32; 3], +} + +// Minimal mirror of `struct v4l2_pix_format` from linux/videodev2.h. +// Keep this private and tested; if more V4L2 APIs are needed, prefer moving to +// generated or crate-backed bindings instead of expanding this by hand. +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct V4l2PixFormat { + width: u32, + height: u32, + pixelformat: u32, + field: u32, + bytesperline: u32, + sizeimage: u32, + colorspace: u32, + priv_: u32, + flags: u32, + ycbcr_enc: u32, + quantization: u32, + xfer_func: u32, +} + +// `struct v4l2_format` contains a large union. Only `pix` is used here, but the +// raw member must preserve the full Linux ABI size so ioctl request numbers +// match the platform headers. +#[repr(C)] +union V4l2FormatUnion { + pix: V4l2PixFormat, + raw_data: [u8; 204], +} + +#[repr(C)] +struct V4l2Format { + type_: u32, + fmt: V4l2FormatUnion, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::offset_of; + + #[test] + fn v4l2_struct_layout_matches_linux_headers() { + assert_eq!(mem::size_of::(), 104); + assert_eq!(mem::size_of::(), 48); + assert_eq!(mem::size_of::(), 208); + assert_eq!(offset_of!(V4l2Format, fmt), 4); + assert_eq!(offset_of!(V4l2PixFormat, pixelformat), 8); + } + + #[test] + fn v4l2_ioctl_numbers_match_linux_headers() { + assert_eq!(vidioc_querycap(), 0x8068_5600); + assert_eq!(vidioc_s_fmt(), 0xc0d0_5605); + } + + #[test] + fn trims_nul_terminated_driver_names() { + let mut driver = [0; 16]; + driver[..V4L2_LOOPBACK_DRIVER.len()].copy_from_slice(V4L2_LOOPBACK_DRIVER); + + assert_eq!(nul_terminated_bytes(&driver), V4L2_LOOPBACK_DRIVER); + } +} diff --git a/crates/pyvirtualcam-py/Cargo.toml b/crates/pyvirtualcam-py/Cargo.toml new file mode 100644 index 0000000..c517c9c --- /dev/null +++ b/crates/pyvirtualcam-py/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "pyvirtualcam-py" +version = "0.0.0" +edition = "2021" +license = "GPL-2.0-only" + +[lib] +name = "_native_linux_v4l2loopback" +crate-type = ["cdylib"] + +[dependencies] +numpy = "0.21" +pyvirtualcam-core = { path = "../pyvirtualcam-core" } +pyo3 = { version = "0.21", features = ["extension-module"] } diff --git a/crates/pyvirtualcam-py/src/lib.rs b/crates/pyvirtualcam-py/src/lib.rs new file mode 100644 index 0000000..928b675 --- /dev/null +++ b/crates/pyvirtualcam-py/src/lib.rs @@ -0,0 +1,96 @@ +use numpy::PyReadonlyArray1; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PySequence, PyString}; + +use pyvirtualcam_core::linux::V4l2LoopbackCamera; +use pyvirtualcam_core::Error; + +#[pyclass] +struct Camera { + camera: V4l2LoopbackCamera, +} + +#[pymethods] +impl Camera { + #[new] + #[pyo3(signature = (*, width, height, fps, fourcc, device=None))] + fn new( + width: u32, + height: u32, + fps: f64, + fourcc: u32, + device: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + if !fps.is_finite() || fps <= 0.0 { + return Err(PyValueError::new_err("fps must be a positive finite number")); + } + // fps is handled by the Python Camera wrapper for pacing; the Linux + // v4l2loopback backend does not configure device frame rate (same as + // the legacy C++ backend). + let _ = fps; + let devices = parse_devices(device)?; + let camera = V4l2LoopbackCamera::new(width, height, fourcc, devices).map_err(to_py_err)?; + Ok(Self { camera }) + } + + fn close(&mut self) { + self.camera.stop(); + } + + fn send(&mut self, frame: PyReadonlyArray1<'_, u8>) -> PyResult<()> { + let frame = frame.as_slice()?; + self.camera.send(frame).map_err(to_py_err) + } + + fn device(&self) -> String { + self.camera.device() + } + + fn native_fourcc(&self) -> u32 { + self.camera.native_fourcc() + } +} + +#[pymodule] +fn _native_linux_v4l2loopback(_py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + Ok(()) +} + +fn parse_devices(device: Option<&Bound<'_, PyAny>>) -> PyResult>> { + let Some(device) = device else { + return Ok(None); + }; + if device.is_none() { + return Ok(None); + } + + if device.downcast::().is_ok() { + return Ok(Some(vec![to_string_like(device)?])); + } + + if let Ok(sequence) = device.downcast::() { + let len = sequence.len()?; + let mut devices = Vec::with_capacity(len); + for index in 0..len { + let item = sequence.get_item(index)?; + devices.push(to_string_like(&item)?); + } + return Ok(Some(devices)); + } + + Ok(Some(vec![to_string_like(device)?])) +} + +fn to_string_like(value: &Bound<'_, PyAny>) -> PyResult { + Ok(value.str()?.to_str()?.to_owned()) +} + +fn to_py_err(error: Error) -> PyErr { + match error { + Error::InvalidArgument(message) => PyValueError::new_err(message), + Error::Runtime(message) => PyRuntimeError::new_err(message), + Error::Io { context, source } => PyRuntimeError::new_err(format!("{context}: {source}")), + } +} diff --git a/docs/conf.py b/docs/conf.py index c539765..62f708f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,11 +93,10 @@ # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - "search_bar_position": "navbar", "icon_links": [ { "name": "GitHub", - "url": "https://github.com/letmaik/pyvirtualcam", + "url": "https://github.com/LuxTronic/pyvirtualcam", "icon": "fab fa-github-square", }, { diff --git a/docs/index.rst b/docs/index.rst index 6b02f95..281a28e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,17 @@ API Reference ============= +Implementation Notes +-------------------- + +The Linux ``v4l2loopback`` backend is implemented in Rust and exposed through +the same Python API documented below. macOS and Windows continue to use the +existing native backends. + +The Rust core crate also includes an initial native Rust API for Linux users. +See ``crates/pyvirtualcam-core/README.md`` and +``crates/pyvirtualcam-core/examples/simple.rs`` in the source tree. + .. autoclass:: pyvirtualcam.Camera :members: :member-order: groupwise diff --git a/examples/README.md b/examples/README.md index 73d2310..1d63b06 100644 --- a/examples/README.md +++ b/examples/README.md @@ -53,3 +53,23 @@ python ./examples/latency.py **Description:** Reads RGBA frames from a GIF animation and sends them to a virtual camera while preserving transparency. Useful for post-processing in other software like OBS Studio. *screencast tbd* + +## Multi Device + +**Source:** [multi_device.py](multi_device.py) + +**Description:** Sends the same frames to multiple Linux `v4l2loopback` devices from Python. + +```sh +python ./examples/multi_device.py +``` + +## Native Rust + +**Source:** [../crates/pyvirtualcam-core/examples/simple.rs](../crates/pyvirtualcam-core/examples/simple.rs) + +**Description:** Demonstrates the initial native Rust API for the Linux `v4l2loopback` backend. + +```sh +cargo run -p pyvirtualcam-core --example simple +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..813ca7b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools>=61", + "wheel", + "pybind11>=2.6.0", + "setuptools-rust>=1.10.2,<1.11.0", +] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 0cbe58d..ba8bfec 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,12 @@ from distutils.unixccompiler import UnixCCompiler +try: + from setuptools_rust import Binding, RustExtension +except ImportError: + Binding = None + RustExtension = None + class get_pybind_include(object): """Helper class to determine the pybind11 include path The purpose of this class is to postpone importing pybind11 @@ -20,6 +26,7 @@ def __str__(self): return pybind11.get_include() ext_modules = [] +rust_extensions = [] common_src = glob.glob('external/libyuv/source/*.cc') common_inc = [get_pybind_include(), 'external/libyuv/include'] @@ -73,14 +80,15 @@ def __str__(self): ) ) elif platform.system() == 'Linux': - ext_modules.append( - Extension('pyvirtualcam._native_linux_v4l2loopback', - # Sort input source files to ensure bit-for-bit reproducible builds - # (https://github.com/pybind/python_example/pull/53) - sorted(['pyvirtualcam/native_linux_v4l2loopback/main.cpp'] + common_src), - include_dirs=['pyvirtualcam/native_linux_v4l2loopback'] + common_inc, - extra_compile_args=['-flto'], - language='c++' + if RustExtension is None: + raise RuntimeError( + "setuptools-rust is required to build the Linux v4l2loopback backend" + ) + rust_extensions.append( + RustExtension( + 'pyvirtualcam._native_linux_v4l2loopback', + path='crates/pyvirtualcam-py/Cargo.toml', + binding=Binding.PyO3, ) ) else: @@ -171,8 +179,13 @@ def build_extensions(self): 'Topic :: Software Development :: Libraries', ], ext_modules=ext_modules, + rust_extensions=rust_extensions, packages = find_packages(), - setup_requires=['pybind11>=2.6.0'], + setup_requires=( + ['pybind11>=2.6.0', 'setuptools-rust>=1.10.2,<1.11.0'] + if platform.system() == 'Linux' + else ['pybind11>=2.6.0'] + ), install_requires=['numpy'], python_requires='>=3.8', cmdclass={'build_ext': BuildExt}, diff --git a/test/test_backend_contract.py b/test/test_backend_contract.py new file mode 100644 index 0000000..a4b46e1 --- /dev/null +++ b/test/test_backend_contract.py @@ -0,0 +1,105 @@ +import pytest +import numpy as np + +import pyvirtualcam +from pyvirtualcam import PixelFormat +from pyvirtualcam.util import encode_fourcc + + +class MockBackend: + instances = [] + + def __init__(self, *, width, height, fps, fourcc, device, **kw): + self.width = width + self.height = height + self.fps = fps + self.fourcc = fourcc + self.device_arg = device + self.kw = kw + self.closed = False + self.frames = [] + MockBackend.instances.append(self) + + def close(self): + self.closed = True + + def send(self, frame): + self.frames.append(frame.copy()) + + def device(self): + return "mock-device" + + def native_fourcc(self): + return self.fourcc + + +@pytest.fixture +def mock_backend(): + previous = pyvirtualcam.camera.BACKENDS.get("mock") + MockBackend.instances = [] + pyvirtualcam.register_backend("mock", MockBackend) + try: + yield MockBackend + finally: + if previous is None: + pyvirtualcam.camera.BACKENDS.pop("mock", None) + else: + pyvirtualcam.register_backend("mock", previous) + + +def test_backend_constructor_contract(mock_backend): + with pyvirtualcam.Camera( + width=4, + height=2, + fps=30, + fmt=PixelFormat.BGR, + device="custom-device", + backend="mock", + custom=True, + ) as cam: + backend = mock_backend.instances[-1] + + assert cam.backend == "mock" + assert cam.device == "mock-device" + assert cam.width == 4 + assert cam.height == 2 + assert cam.fps == 30 + assert cam.fmt == PixelFormat.BGR + assert cam.native_fmt == PixelFormat.BGR + assert backend.device_arg == "custom-device" + assert backend.kw == {"custom": True} + assert backend.fourcc == encode_fourcc(PixelFormat.BGR.value) + + assert backend.closed + + +def test_send_validates_and_flattens_frame(mock_backend): + with pyvirtualcam.Camera(4, 2, 30, backend="mock") as cam: + frame = np.arange(4 * 2 * 3, dtype=np.uint8).reshape(2, 4, 3) + + cam.send(frame) + + backend = mock_backend.instances[-1] + assert cam.frames_sent == 1 + assert len(backend.frames) == 1 + assert backend.frames[0].shape == (4 * 2 * 3,) + assert np.array_equal(backend.frames[0], frame.reshape(-1)) + + +def test_send_rejects_wrong_dtype_and_shape(mock_backend): + with pyvirtualcam.Camera(4, 2, 30, backend="mock") as cam: + with pytest.raises(TypeError): + cam.send(np.zeros((2, 4, 3), dtype=np.uint16)) + + with pytest.raises(ValueError): + cam.send(np.zeros((4, 2, 3), dtype=np.uint8)) + + +def test_close_is_idempotent(mock_backend): + cam = pyvirtualcam.Camera(4, 2, 30, backend="mock") + backend = mock_backend.instances[-1] + + cam.close() + cam.close() + + assert backend.closed diff --git a/test/test_camera.py b/test/test_camera.py index 2d18b56..72cbfc9 100644 --- a/test/test_camera.py +++ b/test/test_camera.py @@ -51,7 +51,7 @@ def test_select_camera_device(backend: str): device = 'OBS Virtual Camera' elif backend == 'unitycapture': device = 'Unity Video Capture' - elif backend == 'v4l2capture': + elif backend == 'v4l2loopback': device = '/dev/video0' else: raise NotImplementedError @@ -65,7 +65,7 @@ def test_select_invalid_camera_device(backend: str): device = 'Foo' elif backend == 'unitycapture': device = 'Unity Video Capture #20' - elif backend == 'v4l2capture': + elif backend == 'v4l2loopback': device = '/dev/video123' else: raise NotImplementedError