From ecbab90962a9c6a5d960988edd435bc9bdf5bd19 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Wed, 3 Jun 2026 23:24:24 -0400 Subject: [PATCH 1/7] feat: port Linux v4l2loopback backend to Rust core Introduce a Rust workspace with shared format conversion and a PyO3 Linux backend while keeping the Python API unchanged. Enable mock tests in Linux CI and document the migration for reviewers. Co-authored-by: Cursor --- .github/scripts/build-linux.sh | 11 +- .github/scripts/build-macos.sh | 2 +- .github/scripts/build-windows.ps1 | 2 +- .github/scripts/test-linux.sh | 18 +- .gitignore | 2 + AGENTS.md | 28 ++ CHANGELOG.md | 10 + Cargo.lock | 354 +++++++++++++ Cargo.toml | 6 + MANIFEST.in | 4 + README.md | 21 +- crates/pyvirtualcam-core/Cargo.toml | 17 + crates/pyvirtualcam-core/README.md | 65 +++ crates/pyvirtualcam-core/build.rs | 47 ++ .../pyvirtualcam-core/cpp/libyuv_wrapper.cpp | 44 ++ crates/pyvirtualcam-core/examples/simple.rs | 32 ++ crates/pyvirtualcam-core/src/camera.rs | 126 +++++ crates/pyvirtualcam-core/src/convert.rs | 92 ++++ crates/pyvirtualcam-core/src/error.rs | 43 ++ crates/pyvirtualcam-core/src/formats.rs | 95 ++++ crates/pyvirtualcam-core/src/fourcc.rs | 38 ++ crates/pyvirtualcam-core/src/lib.rs | 19 + crates/pyvirtualcam-core/src/linux.rs | 476 ++++++++++++++++++ crates/pyvirtualcam-py/Cargo.toml | 14 + crates/pyvirtualcam-py/src/lib.rs | 89 ++++ docs/conf.py | 3 +- docs/index.rst | 11 + examples/README.md | 20 + pyproject.toml | 8 + setup.py | 27 +- test/test_backend_contract.py | 105 ++++ test/test_camera.py | 4 +- 32 files changed, 1798 insertions(+), 35 deletions(-) create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 MANIFEST.in create mode 100644 crates/pyvirtualcam-core/Cargo.toml create mode 100644 crates/pyvirtualcam-core/README.md create mode 100644 crates/pyvirtualcam-core/build.rs create mode 100644 crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp create mode 100644 crates/pyvirtualcam-core/examples/simple.rs create mode 100644 crates/pyvirtualcam-core/src/camera.rs create mode 100644 crates/pyvirtualcam-core/src/convert.rs create mode 100644 crates/pyvirtualcam-core/src/error.rs create mode 100644 crates/pyvirtualcam-core/src/formats.rs create mode 100644 crates/pyvirtualcam-core/src/fourcc.rs create mode 100644 crates/pyvirtualcam-core/src/lib.rs create mode 100644 crates/pyvirtualcam-core/src/linux.rs create mode 100644 crates/pyvirtualcam-py/Cargo.toml create mode 100644 crates/pyvirtualcam-py/src/lib.rs create mode 100644 pyproject.toml create mode 100644 test/test_backend_contract.py diff --git a/.github/scripts/build-linux.sh b/.github/scripts/build-linux.sh index bc97b40..5b8f2f9 100755 --- a/.github/scripts/build-linux.sh +++ b/.github/scripts/build-linux.sh @@ -26,9 +26,18 @@ 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 +source "$HOME/.cargo/env" +rustc --version +cargo --version + # install compile-time dependencies ${PYBIN}/pip install numpy==${NUMPY_VERSION} -${PYBIN}/pip install setuptools +${PYBIN}/pip install setuptools setuptools-rust # List installed packages ${PYBIN}/pip freeze diff --git a/.github/scripts/build-macos.sh b/.github/scripts/build-macos.sh index f6997cd..6a66286 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 setuptools-rust # List installed packages pip freeze diff --git a/.github/scripts/build-windows.ps1 b/.github/scripts/build-windows.ps1 index ec6bcc1..69e8a5d 100644 --- a/.github/scripts/build-windows.ps1 +++ b/.github/scripts/build-windows.ps1 @@ -75,7 +75,7 @@ Get-ChildItem env: # Build the wheel. Create-And-Enter-VEnv build -exec { python -m pip install --upgrade pip wheel setuptools } +exec { python -m pip install --upgrade pip wheel setuptools setuptools-rust } exec { python -m pip install --only-binary :all: numpy==$env:NUMPY_VERSION } exec { python -u setup.py bdist_wheel } Exit-VEnv 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/.gitignore b/.gitignore index ac2fd9e..bebd4ab 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..164dbde --- /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 half_width = width / 2; + int32_t half_height = height_abs / 2; + + libyuv::RAWToI420( + rgb, width * 3, + i420, width, + i420 + width * height_abs, half_width, + i420 + width * height_abs + half_width * half_height, half_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 half_width = width / 2; + int32_t half_height = height_abs / 2; + + libyuv::RGB24ToI420( + bgr, width * 3, + i420, width, + i420 + width * height_abs, half_width, + i420 + width * height_abs + half_width * half_height, half_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..4cb79b6 --- /dev/null +++ b/crates/pyvirtualcam-core/src/convert.rs @@ -0,0 +1,92 @@ +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<()> { + 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..ec6acd9 --- /dev/null +++ b/crates/pyvirtualcam-core/src/error.rs @@ -0,0 +1,43 @@ +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 {} diff --git a/crates/pyvirtualcam-core/src/formats.rs b/crates/pyvirtualcam-core/src/formats.rs new file mode 100644 index 0000000..ec0ffc6 --- /dev/null +++ b/crates/pyvirtualcam-core/src/formats.rs @@ -0,0 +1,95 @@ +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 pixels = width as usize * height as usize; + match self { + Self::Rgb | Self::Bgr => pixels * 3, + Self::Rgba => pixels * 4, + Self::Gray => pixels, + Self::I420 | Self::Nv12 => pixels * 3 / 2, + Self::Yuyv | Self::Uyvy => pixels * 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::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..6465965 --- /dev/null +++ b/crates/pyvirtualcam-core/src/linux.rs @@ -0,0 +1,476 @@ +//! 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); + } + cleanup_open_devices(&camera_fds, &camera_devices); + return Err(err); + } + + active_devices().lock().unwrap().insert(device_name.clone()); + 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 { + if active_devices().lock().unwrap().contains(device_name) { + return Err(Error::invalid_argument(format!( + "Device {device_name} is already in use." + ))); + } + + let c_device_name = c_string(device_name)?; + let camera_fd = unsafe { libc::open(c_device_name.as_ptr(), libc::O_WRONLY | libc::O_SYNC) }; + if camera_fd == -1 { + 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); + } + return Err(err); + } + + Ok(camera_fd) +} + +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::*; + + #[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); + } + + #[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..2486677 --- /dev/null +++ b/crates/pyvirtualcam-py/src/lib.rs @@ -0,0 +1,89 @@ +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, + #[allow(unused_variables)] fps: f64, + fourcc: u32, + device: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + 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..8511c39 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools>=61", + "wheel", + "pybind11>=2.6.0", + "setuptools-rust", +] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 0cbe58d..f0a2706 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,9 @@ 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'], 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 From 28b549d2e2f3f07cfd1d4a5100a6ff901f4a8e94 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Wed, 3 Jun 2026 23:31:44 -0400 Subject: [PATCH 2/7] ci: add luxie-tronic review workflow for PR automation Enable org-standard luxie-tronic reviews on pull requests, with a pyvirtualcam-specific policy covering Rust and Python paths. Co-authored-by: Cursor --- .codex/luxie-tronic-policy.json | 39 +++++++++++++++++++ .github/lux-ci-config.json | 61 ++++++++++++++++++++++++++++++ .github/workflows/luxie-tronic.yml | 33 ++++++++++++++++ .gitignore | 2 + 4 files changed, 135 insertions(+) create mode 100644 .codex/luxie-tronic-policy.json create mode 100644 .github/lux-ci-config.json create mode 100644 .github/workflows/luxie-tronic.yml diff --git a/.codex/luxie-tronic-policy.json b/.codex/luxie-tronic-policy.json new file mode 100644 index 0000000..42adf6a --- /dev/null +++ b/.codex/luxie-tronic-policy.json @@ -0,0 +1,39 @@ +{ + "auto_reviewer_user": "luxie-tronic", + "auto_reviewer_team": "codex-reviewers", + "mode": "review_only", + "fork_pr_behavior": "review_only", + "pytest_command": "pytest -q test/test_backend_contract.py test/test_util.py", + "max_files_changed_by_bot": 12, + "max_lines_changed_by_bot": 240, + "medium_divisor": 300, + "low_divisor": 40, + "human_approval_required": true, + "allowed_paths": [ + "pyvirtualcam/**/*.py", + "pyvirtualcam/**/*.cpp", + "pyvirtualcam/**/*.mm", + "test/**/*.py", + "crates/**/*.rs", + "crates/**/build.rs", + "crates/**/Cargo.toml", + "crates/**/*.cpp", + "examples/**/*.py", + "examples/**/*.rs", + "setup.py", + "pyproject.toml", + "docs/**/*.rst", + "README.md", + "CHANGELOG.md", + "AGENTS.md", + "MANIFEST.in" + ], + "blocked_paths": [ + ".github/**", + "**/*.tf", + "**/*.tfvars", + "**/secrets/**", + "**/*.pem", + "**/*.key" + ] +} diff --git a/.github/lux-ci-config.json b/.github/lux-ci-config.json new file mode 100644 index 0000000..aec73f6 --- /dev/null +++ b/.github/lux-ci-config.json @@ -0,0 +1,61 @@ +{ + "automation_ref": "v1.6", + "review": { + "workflow_name": "luxie-tronic", + "branches": [ + "main" + ], + "types": [ + "opened", + "reopened", + "synchronize", + "ready_for_review", + "review_requested", + "review_request_removed" + ], + "permissions": { + "contents": "write", + "pull-requests": "write", + "issues": "write" + } + }, + "policy": { + "auto_reviewer_user": "luxie-tronic", + "auto_reviewer_team": "codex-reviewers", + "mode": "review_only", + "fork_pr_behavior": "review_only", + "pytest_command": "pytest -q test/test_backend_contract.py test/test_util.py", + "max_files_changed_by_bot": 12, + "max_lines_changed_by_bot": 240, + "medium_divisor": 300, + "low_divisor": 40, + "human_approval_required": true, + "allowed_paths": [ + "pyvirtualcam/**/*.py", + "pyvirtualcam/**/*.cpp", + "pyvirtualcam/**/*.mm", + "test/**/*.py", + "crates/**/*.rs", + "crates/**/build.rs", + "crates/**/Cargo.toml", + "crates/**/*.cpp", + "examples/**/*.py", + "examples/**/*.rs", + "setup.py", + "pyproject.toml", + "docs/**/*.rst", + "README.md", + "CHANGELOG.md", + "AGENTS.md", + "MANIFEST.in" + ], + "blocked_paths": [ + ".github/**", + "**/*.tf", + "**/*.tfvars", + "**/secrets/**", + "**/*.pem", + "**/*.key" + ] + } +} diff --git a/.github/workflows/luxie-tronic.yml b/.github/workflows/luxie-tronic.yml new file mode 100644 index 0000000..19f2f6d --- /dev/null +++ b/.github/workflows/luxie-tronic.yml @@ -0,0 +1,33 @@ +# This file is generated by .github/luxie-workflows/scripts/generate.py. +# Do not edit directly; update .github/lux-ci-config.json and regenerate. + +name: luxie-tronic +'on': + pull_request_target: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + - review_requested + - review_request_removed + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to review + required: true + type: string +permissions: + contents: write + pull-requests: write + issues: write +jobs: + review: + uses: LuxTronic/luxie-workflows/.github/workflows/reusable-luxie-tronic.yml@v1.6 + with: + automation_ref: v1.6 + policy_path: .codex/luxie-tronic-policy.json + pr_number: ${{ github.event.inputs.pr_number || '' }} + secrets: inherit diff --git a/.gitignore b/.gitignore index bebd4ab..342acf8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ target external/miniconda* *.png *.json +!.codex/luxie-tronic-policy.json +!.github/lux-ci-config.json env \ No newline at end of file From d0235fa5ded1e383b7777579550163f4a49f6638 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Wed, 3 Jun 2026 23:33:20 -0400 Subject: [PATCH 3/7] chore: retrigger luxie-tronic review Co-authored-by: Cursor From 69fa3c94078df73e59de052b1f1ef5c85da457c0 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Wed, 3 Jun 2026 23:48:16 -0400 Subject: [PATCH 4/7] fix: address CodeRabbit review findings for Rust Linux backend Fix I420 buffer sizing for odd dimensions, guard V4L2 device reservation, validate conversion dimensions, pin setuptools-rust for Python 3.8, and apply other review-driven hardening. Co-authored-by: Cursor --- .github/scripts/build-linux.sh | 6 ++-- .github/scripts/build-macos.sh | 2 +- .github/scripts/build-windows.ps1 | 2 +- .../pyvirtualcam-core/cpp/libyuv_wrapper.cpp | 16 ++++----- crates/pyvirtualcam-core/src/convert.rs | 6 ++++ crates/pyvirtualcam-core/src/error.rs | 9 ++++- crates/pyvirtualcam-core/src/formats.rs | 18 +++++++--- crates/pyvirtualcam-core/src/linux.rs | 35 +++++++++++++++---- crates/pyvirtualcam-py/src/lib.rs | 9 ++++- pyproject.toml | 2 +- setup.py | 2 +- 11 files changed, 80 insertions(+), 27 deletions(-) diff --git a/.github/scripts/build-linux.sh b/.github/scripts/build-linux.sh index 5b8f2f9..8860ef1 100755 --- a/.github/scripts/build-linux.sh +++ b/.github/scripts/build-linux.sh @@ -31,13 +31,15 @@ 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 -source "$HOME/.cargo/env" +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 setuptools-rust +${PYBIN}/pip install setuptools 'setuptools-rust>=1.10.2,<1.11.0' # List installed packages ${PYBIN}/pip freeze diff --git a/.github/scripts/build-macos.sh b/.github/scripts/build-macos.sh index 6a66286..21be1d8 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 setuptools-rust +pip install numpy==$NUMPY_VERSION wheel delocate setuptools 'setuptools-rust>=1.10.2,<1.11.0' # List installed packages pip freeze diff --git a/.github/scripts/build-windows.ps1 b/.github/scripts/build-windows.ps1 index 69e8a5d..5fc7456 100644 --- a/.github/scripts/build-windows.ps1 +++ b/.github/scripts/build-windows.ps1 @@ -75,7 +75,7 @@ Get-ChildItem env: # Build the wheel. Create-And-Enter-VEnv build -exec { python -m pip install --upgrade pip wheel setuptools setuptools-rust } +exec { python -m pip install --upgrade pip wheel setuptools 'setuptools-rust>=1.10.2,<1.11.0' } exec { python -m pip install --only-binary :all: numpy==$env:NUMPY_VERSION } exec { python -u setup.py bdist_wheel } Exit-VEnv diff --git a/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp b/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp index 164dbde..d87ada4 100644 --- a/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp +++ b/crates/pyvirtualcam-core/cpp/libyuv_wrapper.cpp @@ -14,14 +14,14 @@ extern "C" void pyvc_rgb_to_i420( int32_t height ) { int32_t height_abs = std::abs(height); - int32_t half_width = width / 2; - int32_t half_height = height_abs / 2; + 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, half_width, - i420 + width * height_abs + half_width * half_height, half_width, + i420 + width * height_abs, chroma_width, + i420 + width * height_abs + chroma_width * chroma_height, chroma_width, width, height); } @@ -32,13 +32,13 @@ extern "C" void pyvc_bgr_to_i420( int32_t height ) { int32_t height_abs = std::abs(height); - int32_t half_width = width / 2; - int32_t half_height = height_abs / 2; + 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, half_width, - i420 + width * height_abs + half_width * half_height, half_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/src/convert.rs b/crates/pyvirtualcam-core/src/convert.rs index 4cb79b6..d1619a9 100644 --- a/crates/pyvirtualcam-core/src/convert.rs +++ b/crates/pyvirtualcam-core/src/convert.rs @@ -27,6 +27,12 @@ fn convert_to_i420( 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 { diff --git a/crates/pyvirtualcam-core/src/error.rs b/crates/pyvirtualcam-core/src/error.rs index ec6acd9..db91fd8 100644 --- a/crates/pyvirtualcam-core/src/error.rs +++ b/crates/pyvirtualcam-core/src/error.rs @@ -40,4 +40,11 @@ impl fmt::Display for Error { } } -impl std::error::Error for Error {} +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 index ec0ffc6..c18420f 100644 --- a/crates/pyvirtualcam-core/src/formats.rs +++ b/crates/pyvirtualcam-core/src/formats.rs @@ -45,13 +45,19 @@ impl PixelFormat { } pub fn frame_size(self, width: u32, height: u32) -> usize { - let pixels = width as usize * height as usize; + let width = width as usize; + let height = height as usize; + let pixels = width.saturating_mul(height); match self { - Self::Rgb | Self::Bgr => pixels * 3, - Self::Rgba => pixels * 4, + Self::Rgb | Self::Bgr => pixels.saturating_mul(3), + Self::Rgba => pixels.saturating_mul(4), Self::Gray => pixels, - Self::I420 | Self::Nv12 => pixels * 3 / 2, - Self::Yuyv | Self::Uyvy => pixels * 2, + 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), } } } @@ -68,6 +74,8 @@ mod tests { 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); } diff --git a/crates/pyvirtualcam-core/src/linux.rs b/crates/pyvirtualcam-core/src/linux.rs index 6465965..2e0308e 100644 --- a/crates/pyvirtualcam-core/src/linux.rs +++ b/crates/pyvirtualcam-core/src/linux.rs @@ -98,11 +98,11 @@ impl V4l2LoopbackCamera { unsafe { libc::close(camera_fd); } + release_device(&device_name); cleanup_open_devices(&camera_fds, &camera_devices); return Err(err); } - active_devices().lock().unwrap().insert(device_name.clone()); camera_fds.push(camera_fd); camera_devices.push(device_name); opened_device = true; @@ -251,15 +251,30 @@ fn discover_devices() -> Result> { } fn try_open(device_name: &str) -> Result { - if active_devices().lock().unwrap().contains(device_name) { - return Err(Error::invalid_argument(format!( - "Device {device_name} is already in use." - ))); + { + 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 c_device_name = c_string(device_name)?; + 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) => { @@ -284,12 +299,17 @@ fn try_open(device_name: &str) -> Result { 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) }; @@ -452,12 +472,15 @@ struct V4l2Format { #[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] diff --git a/crates/pyvirtualcam-py/src/lib.rs b/crates/pyvirtualcam-py/src/lib.rs index 2486677..928b675 100644 --- a/crates/pyvirtualcam-py/src/lib.rs +++ b/crates/pyvirtualcam-py/src/lib.rs @@ -18,10 +18,17 @@ impl Camera { fn new( width: u32, height: u32, - #[allow(unused_variables)] fps: f64, + 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 }) diff --git a/pyproject.toml b/pyproject.toml index 8511c39..813ca7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,6 @@ requires = [ "setuptools>=61", "wheel", "pybind11>=2.6.0", - "setuptools-rust", + "setuptools-rust>=1.10.2,<1.11.0", ] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index f0a2706..473c119 100644 --- a/setup.py +++ b/setup.py @@ -181,7 +181,7 @@ def build_extensions(self): ext_modules=ext_modules, rust_extensions=rust_extensions, packages = find_packages(), - setup_requires=['pybind11>=2.6.0', 'setuptools-rust'], + setup_requires=['pybind11>=2.6.0', 'setuptools-rust>=1.10.2,<1.11.0'], install_requires=['numpy'], python_requires='>=3.8', cmdclass={'build_ext': BuildExt}, From b74b92d754735ccf096aafbb6888bc20684e6d12 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Thu, 4 Jun 2026 01:22:36 -0400 Subject: [PATCH 5/7] fix(ci): repair Linux Python 3.8 and 3.13 wheel builds Pin manylinux2014 images that still ship cp38 for 3.8 matrix jobs and enable PyO3 ABI3 forward compatibility when building cp313 wheels. Co-authored-by: Cursor --- .github/scripts/build-linux.sh | 4 ++++ .github/workflows/ci.yml | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/scripts/build-linux.sh b/.github/scripts/build-linux.sh index 8860ef1..f569365 100755 --- a/.github/scripts/build-linux.sh +++ b/.github/scripts/build-linux.sh @@ -46,6 +46,10 @@ ${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/workflows/ci.yml b/.github/workflows/ci.yml index a2bdf15..d65450c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ 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.*' @@ -71,7 +71,7 @@ jobs: - 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.*' @@ -232,7 +232,7 @@ 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.*' @@ -269,7 +269,7 @@ jobs: - 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.*' From 046bdd9932947bb526a0e32e0cd87e06009b1195 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Thu, 4 Jun 2026 08:41:38 -0400 Subject: [PATCH 6/7] ci: migrate Intel macOS builds from macos-13 to macos-15-intel GitHub retired the macos-13 runner image in December 2025, which left Intel matrix jobs queued indefinitely and blocked downstream test/docs. Co-authored-by: Cursor --- .github/workflows/ci.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d65450c..b9c33f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,37 +106,38 @@ jobs: 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' From 658d668b0569e7942af89c7357a6471232fc14af Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Thu, 4 Jun 2026 09:01:15 -0400 Subject: [PATCH 7/7] fix(ci): address CodeRabbit review on build deps and image pins Gate setuptools-rust to Linux-only builds, pin all manylinux matrix images to dated tags, and harden luxie-tronic with SHA-pinned workflow ref and explicit secret passing. Co-authored-by: Cursor --- .github/lux-ci-config.json | 2 +- .github/scripts/build-macos.sh | 2 +- .github/scripts/build-windows.ps1 | 2 +- .github/workflows/ci.yml | 40 +++++++++++++++--------------- .github/workflows/luxie-tronic.yml | 12 ++++++--- setup.py | 6 ++++- 6 files changed, 37 insertions(+), 27 deletions(-) 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-macos.sh b/.github/scripts/build-macos.sh index 21be1d8..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 'setuptools-rust>=1.10.2,<1.11.0' +pip install numpy=="$NUMPY_VERSION" wheel delocate setuptools # List installed packages pip freeze diff --git a/.github/scripts/build-windows.ps1 b/.github/scripts/build-windows.ps1 index 5fc7456..ec6bcc1 100644 --- a/.github/scripts/build-windows.ps1 +++ b/.github/scripts/build-windows.ps1 @@ -75,7 +75,7 @@ Get-ChildItem env: # Build the wheel. Create-And-Enter-VEnv build -exec { python -m pip install --upgrade pip wheel setuptools 'setuptools-rust>=1.10.2,<1.11.0' } +exec { python -m pip install --upgrade pip wheel setuptools } exec { python -m pip install --only-binary :all: numpy==$env:NUMPY_VERSION } exec { python -u setup.py bdist_wheel } Exit-VEnv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9c33f9..3d5c27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,31 +40,31 @@ jobs: 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.*' @@ -77,31 +77,31 @@ jobs: 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.*' @@ -239,31 +239,31 @@ jobs: 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.*' @@ -276,31 +276,31 @@ jobs: 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/setup.py b/setup.py index 473c119..ba8bfec 100644 --- a/setup.py +++ b/setup.py @@ -181,7 +181,11 @@ def build_extensions(self): ext_modules=ext_modules, rust_extensions=rust_extensions, packages = find_packages(), - setup_requires=['pybind11>=2.6.0', 'setuptools-rust>=1.10.2,<1.11.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},