diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..8f79163f --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,164 @@ +name: Rust CI + +on: + push: + branches: [ main, develop ] + paths: + - 'bindings/rust/**' + - 'src/**' + - 'include/**' + - 'CMakeLists.txt' + - '.github/workflows/rust.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'bindings/rust/**' + - 'src/**' + - 'include/**' + - 'CMakeLists.txt' + - '.github/workflows/rust.yml' + +env: + CARGO_TERM_COLOR: always + CCAP_SKIP_CAMERA_TESTS: 1 + +permissions: + contents: read + +jobs: + # Job 1: Static Linking (Development Mode) + # Verifies that the crate links correctly against a pre-built C++ library. + static-link: + name: Static Link (Dev) + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y cmake build-essential pkg-config libclang-dev + + - name: Install system dependencies (Windows) + if: matrix.os == 'windows-latest' + run: | + choco install cmake llvm -y + + - name: Configure LIBCLANG_PATH (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: echo "LIBCLANG_PATH=C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_ENV + + - name: Install system dependencies (macOS) + if: matrix.os == 'macos-latest' + run: | + brew install cmake llvm + echo "LIBCLANG_PATH=$(brew --prefix llvm)/lib" >> $GITHUB_ENV + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: clippy, rustfmt + cache: false + + # Build C++ Library (Linux/macOS) + # We build in build/Debug to match build.rs expectations for Unix Makefiles + - name: Build C library (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p build/Debug + cd build/Debug + cmake -DCMAKE_BUILD_TYPE=Debug -DCCAP_BUILD_EXAMPLES=OFF -DCCAP_BUILD_TESTS=OFF ../.. + cmake --build . --config Debug --parallel + + # Build C++ Library (Windows) + # Build both Debug and Release versions + # MSVC is a multi-config generator, so we build to build/ and specify config at build time + - name: Build C library (Windows) + if: runner.os == 'Windows' + run: | + mkdir build + cd build + cmake -DCCAP_BUILD_EXAMPLES=OFF -DCCAP_BUILD_TESTS=OFF .. + cmake --build . --config Debug --parallel + cmake --build . --config Release --parallel + + - name: Check formatting + if: matrix.os == 'ubuntu-latest' + working-directory: bindings/rust + run: cargo fmt -- --check + + - name: Run clippy + if: matrix.os == 'ubuntu-latest' + working-directory: bindings/rust + run: cargo clippy --all-targets --no-default-features --features static-link -- -D warnings + + - name: Build Rust bindings + working-directory: bindings/rust + run: cargo build --verbose --no-default-features --features static-link + + - name: Run tests + working-directory: bindings/rust + run: cargo test --verbose --no-default-features --features static-link + + # Job 2: Source Build (Distribution Mode) + # Verifies that the crate builds correctly from source using the cc crate. + # This is crucial for crates.io distribution. + build-source: + name: Build Source (Dist) + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config + + - name: Install system dependencies (macOS) + if: matrix.os == 'macos-latest' + run: brew install llvm + + - name: Install system dependencies (Windows) + if: matrix.os == 'windows-latest' + run: | + choco install llvm -y + refreshenv + + - name: Configure LIBCLANG_PATH (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: echo "LIBCLANG_PATH=C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_ENV + + - name: Configure LIBCLANG_PATH (macOS) + if: matrix.os == 'macos-latest' + run: echo "LIBCLANG_PATH=$(brew --prefix llvm)/lib" >> $GITHUB_ENV + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: false + # The build.rs script should handle it via the 'build-source' feature. + + - name: Build Rust bindings (Source) + working-directory: bindings/rust + # Disable default features (static-link) and enable build-source + run: cargo build --verbose --no-default-features --features build-source + + - name: Run tests (Source) + working-directory: bindings/rust + run: cargo test --verbose --no-default-features --features build-source diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b7b455ab..73e7ff89 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1304,6 +1304,106 @@ "problemMatcher": "$msCompile" } }, + { + "label": "Run Rust print_camera", + "type": "shell", + "command": "cargo", + "args": [ + "run", + "--example", + "print_camera" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, + { + "label": "Run Rust minimal_example", + "type": "shell", + "command": "cargo", + "args": [ + "run", + "--example", + "minimal_example" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, + { + "label": "Run Rust capture_grab", + "type": "shell", + "command": "cargo", + "args": [ + "run", + "--example", + "capture_grab" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, + { + "label": "Run Rust capture_callback", + "type": "shell", + "command": "cargo", + "args": [ + "run", + "--example", + "capture_callback" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, + { + "label": "Build Rust Bindings", + "type": "shell", + "command": "cargo", + "args": [ + "build" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, + { + "label": "Build Rust Examples", + "type": "shell", + "command": "cargo", + "args": [ + "build", + "--examples" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, + { + "label": "Test Rust Bindings", + "type": "shell", + "command": "cargo", + "args": [ + "test" + ], + "options": { + "cwd": "${workspaceFolder}/bindings/rust" + }, + "group": "build", + "problemMatcher": "$rustc" + }, { "label": "Run ccap CLI --video test.mp4 (Debug)", "type": "shell", diff --git a/CMakeLists.txt b/CMakeLists.txt index 0db213fb..190314ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -373,4 +373,37 @@ if (CCAP_INSTALL) message(STATUS " Libraries: ${CMAKE_INSTALL_PREFIX}/${CCAP_INSTALL_LIBDIR}") message(STATUS " Headers: ${CMAKE_INSTALL_PREFIX}/${CCAP_INSTALL_INCLUDEDIR}") message(STATUS " CMake: ${CMAKE_INSTALL_PREFIX}/${CCAP_INSTALL_CMAKEDIR}") -endif () \ No newline at end of file +endif () + +# ############### Rust Bindings ################ +option(CCAP_BUILD_RUST "Build Rust bindings" OFF) + +if (CCAP_BUILD_RUST) + message(STATUS "ccap: Building Rust bindings") + # Find Rust/Cargo + find_program(CARGO_CMD cargo) + if (NOT CARGO_CMD) + message(WARNING "cargo not found - Rust bindings disabled") + else () + message(STATUS "ccap: Found cargo: ${CARGO_CMD}") + # Add custom target to build Rust bindings + add_custom_target(ccap-rust + COMMAND ${CARGO_CMD} build --release --no-default-features --features static-link + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings/rust + COMMENT "Building Rust bindings" + DEPENDS ccap + ) + # Add custom target to test Rust bindings + add_custom_target(ccap-rust-test + COMMAND ${CARGO_CMD} test --no-default-features --features static-link + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings/rust + COMMENT "Testing Rust bindings" + DEPENDS ccap-rust + ) + # Rust bindings are optional, do not add to main build automatically + # Users can explicitly build with: cmake --build . --target ccap-rust + message(STATUS "ccap: Rust bindings targets added:") + message(STATUS " ccap-rust: Build Rust bindings") + message(STATUS " ccap-rust-test: Test Rust bindings") + endif () +endif () diff --git a/README.md b/README.md index b067018e..c5b71ae6 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Windows Build](https://github.com/wysaid/CameraCapture/actions/workflows/windows-build.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/windows-build.yml) [![macOS Build](https://github.com/wysaid/CameraCapture/actions/workflows/macos-build.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/macos-build.yml) [![Linux Build](https://github.com/wysaid/CameraCapture/actions/workflows/linux-build.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/linux-build.yml) +[![Rust CI](https://github.com/wysaid/CameraCapture/actions/workflows/rust.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/rust.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![C++17](https://img.shields.io/badge/C++-17-blue.svg)](https://isocpp.org/) [![C99](https://img.shields.io/badge/C-99-blue.svg)](https://en.wikipedia.org/wiki/C99) @@ -10,7 +11,7 @@ [English](./README.md) | [中文](./README.zh-CN.md) -A high-performance, lightweight cross-platform camera capture library with hardware-accelerated pixel format conversion, supporting both camera capture and video file playback (Windows/macOS), providing complete C++ and pure C language interfaces. +A high-performance, lightweight cross-platform camera capture library with hardware-accelerated pixel format conversion, supporting both camera capture and video file playback (Windows/macOS). Provides complete C++ and pure C interfaces, plus Rust bindings. > 🌐 **Official Website:** [ccap.work](https://ccap.work) @@ -207,6 +208,27 @@ int main() { } ``` +### Rust Bindings + +Rust bindings are available as a crate on crates.io: + +- Crate: [ccap-rs on crates.io](https://crates.io/crates/ccap-rs) +- Docs: [docs.rs/ccap-rs](https://docs.rs/ccap-rs) +- Source: `bindings/rust/` + +Quick install: + +```bash +cargo add ccap-rs +``` + +Or, if you want the crate name in code to be `ccap`: + +```toml +[dependencies] +ccap = { package = "ccap-rs", version = "" } +``` + ## CLI Tool ccap includes a powerful command-line tool for quick camera operations and video processing without writing code: @@ -234,6 +256,7 @@ cmake --build . ``` **Key Features:** + - 📷 List and select camera devices - 🎯 Capture single or multiple images - 👁️ Real-time preview window (with GLFW) @@ -243,12 +266,13 @@ cmake --build . - ⏱️ Duration-based or count-based capture modes - 🔁 Video looping and playback speed control + For complete CLI documentation, see [CLI Tool Guide](./docs/content/cli.md). ## System Requirements | Platform | Compiler | System Requirements | -|----------|----------|---------------------| +| -------- | -------- | ------------------- | | **Windows** | MSVC 2019+ (including 2026) / MinGW-w64 | DirectShow | | **macOS** | Xcode 11+ | macOS 10.13+ | | **iOS** | Xcode 11+ | iOS 13.0+ | @@ -268,7 +292,7 @@ For complete CLI documentation, see [CLI Tool Guide](./docs/content/cli.md). ## Examples | Example | Description | Language | Platform | -|---------|-------------|----------|----------| +| ------- | ----------- | -------- | -------- | | [0-print_camera](./examples/desktop/0-print_camera.cpp) / [0-print_camera_c](./examples/desktop/0-print_camera_c.c) | List available cameras | C++ / C | Desktop | | [1-minimal_example](./examples/desktop/1-minimal_example.cpp) / [1-minimal_example_c](./examples/desktop/1-minimal_example_c.c) | Basic frame capture | C++ / C | Desktop | | [2-capture_grab](./examples/desktop/2-capture_grab.cpp) / [2-capture_grab_c](./examples/desktop/2-capture_grab_c.c) | Continuous capture | C++ / C | Desktop | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6be1a38b..fbb63825 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,6 +3,7 @@ [![Windows Build](https://github.com/wysaid/CameraCapture/actions/workflows/windows-build.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/windows-build.yml) [![macOS Build](https://github.com/wysaid/CameraCapture/actions/workflows/macos-build.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/macos-build.yml) [![Linux Build](https://github.com/wysaid/CameraCapture/actions/workflows/linux-build.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/linux-build.yml) +[![Rust CI](https://github.com/wysaid/CameraCapture/actions/workflows/rust.yml/badge.svg)](https://github.com/wysaid/CameraCapture/actions/workflows/rust.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![C++17](https://img.shields.io/badge/C++-17-blue.svg)](https://isocpp.org/) [![C99](https://img.shields.io/badge/C-99-blue.svg)](https://en.wikipedia.org/wiki/C99) @@ -10,7 +11,7 @@ [English](./README.md) | [中文](./README.zh-CN.md) -高性能、轻量级的跨平台相机捕获库,支持硬件加速的像素格式转换,同时支持相机捕获和视频文件播放(Windows/macOS),提供完整的 C++ 和纯 C 语言接口。 +高性能、轻量级的跨平台相机捕获库,支持硬件加速的像素格式转换,同时支持相机捕获和视频文件播放(Windows/macOS),提供完整的 C++ / 纯 C 语言接口,并提供 Rust bindings。 > 🌐 **官方网站:** [ccap.work](https://ccap.work) @@ -170,6 +171,27 @@ int main() { } ``` +### Rust 绑定 + +本项目提供 Rust bindings(已发布到 crates.io): + +- Crate:https://crates.io/crates/ccap-rs +- 文档:https://docs.rs/ccap-rs +- 源码:`bindings/rust/` + +快速安装: + +```bash +cargo add ccap-rs +``` + +如果你希望在代码里使用 `ccap` 作为 crate 名称(推荐),可以在 `Cargo.toml` 中这样写: + +```toml +[dependencies] +ccap = { package = "ccap-rs", version = "" } +``` + ## 命令行工具 ccap 包含一个功能强大的命令行工具,无需编写代码即可快速进行相机操作和视频处理: diff --git a/bindings/rust/.gitignore b/bindings/rust/.gitignore new file mode 100644 index 00000000..d59276fb --- /dev/null +++ b/bindings/rust/.gitignore @@ -0,0 +1,2 @@ +target/ +image_capture/ diff --git a/bindings/rust/Cargo.lock b/bindings/rust/Cargo.lock new file mode 100644 index 00000000..a14d50de --- /dev/null +++ b/bindings/rust/Cargo.lock @@ -0,0 +1,474 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "bindgen" +version = "0.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "ccap-rs" +version = "1.5.0" +dependencies = [ + "bindgen", + "cc", + "thiserror", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.53.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" diff --git a/bindings/rust/Cargo.toml b/bindings/rust/Cargo.toml new file mode 100644 index 00000000..1ab852d0 --- /dev/null +++ b/bindings/rust/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "ccap-rs" +version = "1.5.0" +edition = "2021" +rust-version = "1.65" +authors = ["wysaid "] +license = "MIT" +description = "Rust bindings for ccap - A high-performance, lightweight cross-platform camera capture library" +homepage = "https://ccap.work" +repository = "https://github.com/wysaid/CameraCapture" +documentation = "https://docs.rs/ccap-rs" +readme = "README.md" +keywords = ["camera", "capture", "v4l2", "directshow", "avfoundation"] +categories = ["multimedia::video", "api-bindings"] + + + +[lib] +# Keep the Rust crate name as `ccap` so users can write: +# ccap = { package = "ccap-rs", version = "..." } +# and still `use ccap::Provider;` +name = "ccap" + +[dependencies] +thiserror = "1.0" + +[build-dependencies] +bindgen = "0.68" +cc = "1.0" + +[features] +default = ["build-source"] +static-link = [] # Link against pre-built static library (for development) +build-source = [] # Build from source using cc crate (for distribution) + +[[example]] +name = "print_camera" +path = "examples/print_camera.rs" + +[[example]] +name = "minimal_example" +path = "examples/minimal_example.rs" + +[[example]] +name = "capture_grab" +path = "examples/capture_grab.rs" + +[[example]] +name = "capture_callback" +path = "examples/capture_callback.rs" diff --git a/bindings/rust/README.md b/bindings/rust/README.md new file mode 100644 index 00000000..bd49e653 --- /dev/null +++ b/bindings/rust/README.md @@ -0,0 +1,211 @@ +# ccap-rs - Rust Bindings for CameraCapture + +[![Crates.io](https://img.shields.io/crates/v/ccap-rs.svg)](https://crates.io/crates/ccap-rs) +[![Documentation](https://docs.rs/ccap-rs/badge.svg)](https://docs.rs/ccap-rs) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Safe Rust bindings for [ccap](https://github.com/wysaid/CameraCapture) - A high-performance, lightweight cross-platform camera capture library with hardware-accelerated pixel format conversion. + +## Features + +- **High Performance**: Hardware-accelerated pixel format conversion with up to 10x speedup (AVX2, Apple Accelerate, NEON) +- **Cross Platform**: Windows (DirectShow), macOS/iOS (AVFoundation), Linux (V4L2) +- **Multiple Formats**: RGB, BGR, YUV (NV12/I420) with automatic conversion +- **Zero Dependencies**: Uses only system frameworks +- **Memory Safe**: Safe Rust API with automatic resource management + +## Quick Start + +### Add dependency + +Recommended (always gets the latest published version): + +```bash +cargo add ccap-rs +``` + +If you want the crate name in code to be `ccap` (recommended), set it explicitly in your `Cargo.toml`: + +```toml +[dependencies] +ccap = { package = "ccap-rs", version = "" } +``` + +> Tip: Replace `` with the latest version shown on + +### Basic Usage + +```rust +use ccap::{Provider, Result}; + +fn main() -> Result<()> { + // Create a camera provider + let mut provider = Provider::new()?; + + // Find available cameras + let devices = provider.find_device_names()?; + println!("Found {} cameras:", devices.len()); + for (i, device) in devices.iter().enumerate() { + println!(" [{}] {}", i, device); + } + + // Open the first camera + if !devices.is_empty() { + provider.open_device(Some(&devices[0]), true)?; + println!("Camera opened successfully!"); + + // Capture a frame (with 3 second timeout) + if let Some(frame) = provider.grab_frame(3000)? { + let info = frame.info()?; + println!("Captured frame: {}x{}, format: {:?}", + info.width, info.height, info.pixel_format); + + // Access frame data + let data = frame.data()?; + println!("Frame data size: {} bytes", data.len()); + } + } + + Ok(()) +} +``` + +## Examples + +The crate includes several examples: + +```bash +# List available cameras and their info +cargo run --example print_camera + +# Minimal capture example +cargo run --example minimal_example + +# Capture frames using grab mode +cargo run --example capture_grab + +# Capture frames using callback mode +cargo run --example capture_callback +``` + +## Building + +### Feature Modes + +This crate supports two build modes: + +- **Distribution mode (default):** `build-source` — Builds the native C/C++ implementation via the `cc` crate (intended for crates.io users). +- **Development mode:** `static-link` — Links against a pre-built native library from a CameraCapture checkout (e.g. `build/Debug/libccap.a`) (intended for developing this repository). + +### Prerequisites + +If you are using **development mode** (`static-link`), you need to build the native library first: + +```bash +# From the root of the CameraCapture project +./scripts/build_and_install.sh +``` + +Or manually: + +```bash +mkdir -p build/Debug +cd build/Debug +cmake ../.. -DCMAKE_BUILD_TYPE=Debug +make -j$(nproc) +``` + +### Building the Rust Crate + +```bash +# From bindings/rust directory +cargo build +cargo test +``` + +### Using development mode (`static-link`) + +```bash +# Link against pre-built build/Debug or build/Release from the repo +cargo build --no-default-features --features static-link +cargo test --no-default-features --features static-link +``` + +#### AddressSanitizer (ASan) and `static-link` + +The CameraCapture repo's test scripts may build the native library with **ASan enabled** (e.g. Debug functional tests). +An ASan-instrumented `libccap.a` requires the ASan runtime at link/run time. + +- When using `static-link`, `build.rs` will **only** link the ASan runtime **if it detects** ASan symbols inside the prebuilt `libccap.a`. +- This does **not** affect the default crates.io build (`build-source`). +- You can disable the auto-link behavior by setting `CCAP_RUST_NO_ASAN_LINK=1`. + +## Feature flags + +- `build-source` (default): build the C/C++ ccap sources during `cargo build` (best for crates.io usage). +- `static-link`: link against a pre-built static library from a CameraCapture checkout (best for development). If you use this mode, make sure you have built the C/C++ project first, and set `CCAP_SOURCE_DIR` when needed. + +## Platform notes + +- Camera capture: Windows (DirectShow), macOS/iOS (AVFoundation), Linux (V4L2) +- Video file playback support depends on the underlying C/C++ library backend (currently Windows/macOS only). + +## API Documentation + +### Core Types + +- `Provider`: Main camera capture interface +- `VideoFrame`: Represents a captured video frame +- `DeviceInfo`: Camera device information +- `PixelFormat`: Supported pixel formats (RGB24, BGR24, NV12, I420, etc.) +- `Resolution`: Frame resolution specification + +### Error Handling + +All operations return `Result` for comprehensive error handling. +For frame capture, `grab_frame(timeout_ms)` returns `Result, CcapError>`: + +```rust +match provider.grab_frame(3000) { // 3 second timeout + Ok(Some(frame)) => { /* process frame */ }, + Ok(None) => println!("No frame available"), + Err(e) => eprintln!("Capture error: {:?}", e), +} +``` + +### Thread Safety + +- `VideoFrame` implements `Send` so frames can be moved across threads (e.g. processed/dropped on a worker thread) +- `Provider` implements `Send` but the underlying C++ API is **not thread-safe** + - Use `Provider` from a single thread, or wrap it with `Arc>` for multi-threaded access +- Frame data remains valid until the `VideoFrame` is dropped + +> **Note**: The C++ layer's thread-safety is based on code inspection. If you encounter issues with cross-thread frame usage, please report them. + +## Platform Support + +| Platform | Backend | Status | +| -------- | ------------ | ------------ | +| Windows | DirectShow | ✅ Supported | +| macOS | AVFoundation | ✅ Supported | +| iOS | AVFoundation | ✅ Supported | +| Linux | V4L2 | ✅ Supported | + +## System Requirements + +- Rust 1.65+ +- CMake 3.14+ +- Platform-specific camera frameworks (automatically linked) + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](../../LICENSE) file for details. + +## Related Projects + +- [ccap (C/C++)](https://github.com/wysaid/CameraCapture) - The underlying C/C++ library +- [OpenCV](https://opencv.org/) - Alternative computer vision library with camera support diff --git a/bindings/rust/build.rs b/bindings/rust/build.rs new file mode 100644 index 00000000..5adb5d90 --- /dev/null +++ b/bindings/rust/build.rs @@ -0,0 +1,502 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn file_contains_bytes(path: &Path, needle: &[u8]) -> bool { + let Ok(data) = fs::read(path) else { + return false; + }; + if needle.is_empty() { + return false; + } + data.windows(needle.len()).any(|w| w == needle) +} + +fn clang_resource_dir() -> Option { + // Prefer clang in PATH. + if let Ok(out) = Command::new("clang").arg("--print-resource-dir").output() { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let p = s.trim(); + if !p.is_empty() { + return Some(PathBuf::from(p)); + } + } + } + + // Fallback to xcrun on macOS. + if let Ok(out) = Command::new("xcrun") + .args(["--sdk", "macosx", "--find", "clang"]) + .output() + { + if out.status.success() { + let clang_path = String::from_utf8_lossy(&out.stdout); + let clang_path = clang_path.trim(); + if !clang_path.is_empty() { + if let Ok(out2) = Command::new(clang_path) + .arg("--print-resource-dir") + .output() + { + if out2.status.success() { + let s = String::from_utf8_lossy(&out2.stdout); + let p = s.trim(); + if !p.is_empty() { + return Some(PathBuf::from(p)); + } + } + } + } + } + } + + None +} + +fn looks_like_ccap_root(dir: &Path) -> bool { + dir.join("include/ccap_c.h").exists() && dir.join("src/ccap_core.cpp").exists() +} + +fn find_ccap_root_from(start: &Path) -> Option { + // Walk up a reasonable number of parents to find the repo root. + // This fixes cases like `cargo publish --dry-run` where the manifest dir + // becomes: /bindings/rust/target/package/- + let mut cur = Some(start); + for _ in 0..16 { + let dir = cur?; + if looks_like_ccap_root(dir) { + return Some(dir.to_path_buf()); + } + cur = dir.parent(); + } + None +} + +fn main() { + // Re-run build script when the build script itself changes. + println!("cargo:rerun-if-changed=build.rs"); + // Re-run when wrapper changes (bindgen input). + println!("cargo:rerun-if-changed=wrapper.h"); + // Allow users to override the source checkout location. + println!("cargo:rerun-if-env-changed=CCAP_SOURCE_DIR"); + // Allow users to opt out ASan runtime auto-link (for static-link + ASan prebuilt libs). + println!("cargo:rerun-if-env-changed=CCAP_RUST_NO_ASAN_LINK"); + + // Tell cargo to look for shared libraries in the specified directory + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let manifest_path = PathBuf::from(&manifest_dir); + + // Check if we should build from source or link against pre-built library. + // NOTE: We treat `build-source` and `static-link` differently regarding source root: + // - build-source should prefer vendored ./native for crates.io friendliness. + // - static-link should prefer the repo root / CCAP_SOURCE_DIR so it can find build/Debug|Release. + let build_from_source = env::var("CARGO_FEATURE_BUILD_SOURCE").is_ok(); + let static_link = env::var("CARGO_FEATURE_STATIC_LINK").is_ok(); + + // Locate ccap root. + // build-source path (distribution): prefer ./native for crates.io. + // static-link path (development): prefer repo root / CCAP_SOURCE_DIR for build artifacts. + let (ccap_root, _is_packaged) = if build_from_source { + // 1) Vendored sources under ./native (ideal for crates.io) + if manifest_path.join("native").exists() { + (manifest_path.join("native"), true) + } else if let Some(root) = find_ccap_root_from(&manifest_path) { + // 2) Search parent dirs for CameraCapture repo root (works for git checkout + // and for `cargo publish --dry-run` which builds from target/package) + (root, false) + } else if let Ok(root) = env::var("CCAP_SOURCE_DIR") { + // 3) Allow override via CCAP_SOURCE_DIR + let root = PathBuf::from(root); + if looks_like_ccap_root(&root) { + (root, false) + } else { + panic!( + "CCAP_SOURCE_DIR is set but does not look like CameraCapture root: {}", + root.display() + ); + } + } else { + // Keep a placeholder; if build-source is enabled we'll error with a clear message. + (manifest_path.clone(), false) + } + } else { + // Dev/static-link mode: even if ./native exists, we still prefer the repo root so we can + // link against pre-built build/Debug|Release artifacts. + if let Some(root) = find_ccap_root_from(&manifest_path) { + (root, false) + } else if let Ok(root) = env::var("CCAP_SOURCE_DIR") { + let root = PathBuf::from(root); + if looks_like_ccap_root(&root) { + (root, false) + } else { + panic!( + "CCAP_SOURCE_DIR is set but does not look like CameraCapture root: {}", + root.display() + ); + } + } else if static_link { + panic!( + "static-link feature is enabled, but CameraCapture repo root was not found.\n\ +\ +Tried (in order):\n\ + - searching parent directories for include/ccap_c.h and src/ccap_core.cpp\n\ + - CCAP_SOURCE_DIR environment variable\n\ +\ +Please set CCAP_SOURCE_DIR to a CameraCapture checkout (with build/Debug|Release built)." + ); + } else { + // Fallback placeholder. + (manifest_path.clone(), false) + } + }; + + if build_from_source { + if !looks_like_ccap_root(&ccap_root) { + panic!( + "build-source feature is enabled, but CameraCapture sources were not found.\n\ +\ +Tried (in order):\n\ + - ./native (vendored) under the crate root\n\ + - searching parent directories for include/ccap_c.h and src/ccap_core.cpp\n\ + - CCAP_SOURCE_DIR environment variable\n\ +\ +Please vendor the sources into bindings/rust/native/, or set CCAP_SOURCE_DIR to a CameraCapture checkout." + ); + } + + // Build from source using cc crate + let mut build = cc::Build::new(); + + // Add source files (excluding SIMD-specific files) + build + .file(ccap_root.join("src/ccap_core.cpp")) + .file(ccap_root.join("src/ccap_utils.cpp")) + .file(ccap_root.join("src/ccap_convert.cpp")) + .file(ccap_root.join("src/ccap_convert_frame.cpp")) + .file(ccap_root.join("src/ccap_imp.cpp")) + .file(ccap_root.join("src/ccap_c.cpp")) + .file(ccap_root.join("src/ccap_utils_c.cpp")) + .file(ccap_root.join("src/ccap_convert_c.cpp")); + + // Platform specific sources + #[cfg(target_os = "macos")] + { + build + .file(ccap_root.join("src/ccap_imp_apple.mm")) + .file(ccap_root.join("src/ccap_convert_apple.cpp")) + .file(ccap_root.join("src/ccap_file_reader_apple.mm")); + } + + #[cfg(target_os = "linux")] + { + build.file(ccap_root.join("src/ccap_imp_linux.cpp")); + } + + #[cfg(target_os = "windows")] + { + build + .file(ccap_root.join("src/ccap_imp_windows.cpp")) + .file(ccap_root.join("src/ccap_file_reader_windows.cpp")); + } + + // Include directories + build + .include(ccap_root.join("include")) + .include(ccap_root.join("src")); + + // Compiler flags + build.cpp(true).std("c++17"); // Use C++17 + + // Enable file playback support + build.define("CCAP_ENABLE_FILE_PLAYBACK", "1"); + + #[cfg(target_os = "macos")] + { + build.flag("-fobjc-arc"); // Enable ARC for Objective-C++ + } + + // Compile + build.compile("ccap"); + + // Build SIMD-specific files separately with appropriate flags + // Always build AVX2 file for hasAVX2()/canUseAVX2() symbols + // On non-x86 architectures, ENABLE_AVX2_IMP will be 0 and functions return false + { + let mut avx2_build = cc::Build::new(); + avx2_build + .file(ccap_root.join("src/ccap_convert_avx2.cpp")) + .include(ccap_root.join("include")) + .include(ccap_root.join("src")) + .cpp(true) + .std("c++17"); + + // Only add SIMD flags on x86/x86_64 architectures + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + { + // Only add SIMD flags on non-MSVC compilers + if !avx2_build.get_compiler().is_like_msvc() { + avx2_build.flag("-mavx2").flag("-mfma"); + } else { + // MSVC uses /arch:AVX2 + avx2_build.flag("/arch:AVX2"); + } + } + + avx2_build.compile("ccap_avx2"); + } + + // Always build neon file for hasNEON() symbol + // On non-ARM architectures, ENABLE_NEON_IMP will be 0 and function returns false + { + let mut neon_build = cc::Build::new(); + neon_build + .file(ccap_root.join("src/ccap_convert_neon.cpp")) + .include(ccap_root.join("include")) + .include(ccap_root.join("src")) + .cpp(true) + .std("c++17"); + + // Only add NEON flags on aarch64 + #[cfg(target_arch = "aarch64")] + { + // NEON is always available on aarch64, no special flags needed + } + + neon_build.compile("ccap_neon"); + } + + println!("cargo:warning=Building ccap from source..."); + } else { + // Link against pre-built library (Development mode) + // Determine build profile + let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); + let build_type = if profile == "release" { + "Release" + } else { + "Debug" + }; + + // If the prebuilt static library was compiled with AddressSanitizer (ASan), we must link + // the ASan runtime as well. The repo's default functional test build enables ASan for + // Debug builds (see scripts/run_tests.sh), so this situation is expected. + // + // We detect this by scanning the archive bytes for common ASan symbols. + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if env::var("CCAP_RUST_NO_ASAN_LINK").is_err() + && (target_os == "macos" || target_os == "linux") + { + let archive_path = ccap_root.join("build").join(build_type).join("libccap.a"); + + let asan_instrumented = file_contains_bytes(&archive_path, b"___asan_init") + || file_contains_bytes(&archive_path, b"__asan_init"); + + if asan_instrumented { + // rustc links with `-nodefaultlibs` which can prevent clang from automatically + // adding the ASan runtime, even if `-fsanitize=address` is present. + // We therefore explicitly link the runtime. + println!("cargo:rustc-link-arg=-fsanitize=address"); + + if target_os == "linux" { + // Requires libasan (e.g. Ubuntu: libasan6) to be installed. + println!("cargo:rustc-link-lib=asan"); + } + + if target_os == "macos" { + // Prefer the ASan runtime shipped with the active clang toolchain. + if let Some(resource_dir) = clang_resource_dir() { + let runtime_dir = resource_dir.join("lib").join("darwin"); + let dylib = runtime_dir.join("libclang_rt.asan_osx_dynamic.dylib"); + if dylib.exists() { + println!("cargo:rustc-link-search=native={}", runtime_dir.display()); + // Ensure the runtime dylib can be found at execution time. + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", runtime_dir.display()); + } + } + println!("cargo:rustc-link-lib=dylib=clang_rt.asan_osx_dynamic"); + } + + println!( + "cargo:warning=Prebuilt {} appears to be ASan-instrumented; linking ASan runtime. Set CCAP_RUST_NO_ASAN_LINK=1 to disable.", + archive_path.display() + ); + } + } + + // Add the ccap library search path + // Try specific build type first, then fallback to others + println!( + "cargo:rustc-link-search=native={}/build/{}", + ccap_root.display(), + build_type + ); + println!( + "cargo:rustc-link-search=native={}/build/Debug", + ccap_root.display() + ); + println!( + "cargo:rustc-link-search=native={}/build/Release", + ccap_root.display() + ); + + // Link to ccap library + // Note: On MSVC, we always link to the Release version (ccap.lib) + // to avoid CRT mismatch issues, since Rust uses the release CRT + // even in debug builds by default + println!("cargo:rustc-link-lib=static=ccap"); + + println!("cargo:warning=Linking against pre-built ccap library (dev mode)..."); + } + + // Platform-specific linking (Common for both modes) + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=AVFoundation"); + println!("cargo:rustc-link-lib=framework=CoreMedia"); + println!("cargo:rustc-link-lib=framework=CoreVideo"); + println!("cargo:rustc-link-lib=framework=Accelerate"); + println!("cargo:rustc-link-lib=System"); + println!("cargo:rustc-link-lib=c++"); + } + + #[cfg(target_os = "linux")] + { + // v4l2 might not be available on all systems + // println!("cargo:rustc-link-lib=v4l2"); + println!("cargo:rustc-link-lib=stdc++"); + } + + #[cfg(target_os = "windows")] + { + println!("cargo:rustc-link-lib=strmiids"); + println!("cargo:rustc-link-lib=ole32"); + println!("cargo:rustc-link-lib=oleaut32"); + // Media Foundation libraries for video file playback + println!("cargo:rustc-link-lib=mfplat"); + println!("cargo:rustc-link-lib=mfreadwrite"); + println!("cargo:rustc-link-lib=mfuuid"); + } + + // Use ccap_root for include paths to work in both packaged and repo modes. + println!( + "cargo:rerun-if-changed={}/include/ccap_c.h", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/include/ccap_utils_c.h", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/include/ccap_convert_c.h", + ccap_root.display() + ); + + // If we're compiling from source, also re-run when the vendored/source files change. + if build_from_source { + println!( + "cargo:rerun-if-changed={}/src/ccap_core.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_utils.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_convert.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_convert_frame.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_imp.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_c.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_utils_c.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_convert_c.cpp", + ccap_root.display() + ); + + // Platform-specific sources + #[cfg(target_os = "macos")] + { + println!( + "cargo:rerun-if-changed={}/src/ccap_imp_apple.mm", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_convert_apple.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_file_reader_apple.mm", + ccap_root.display() + ); + } + + #[cfg(target_os = "linux")] + { + println!( + "cargo:rerun-if-changed={}/src/ccap_imp_linux.cpp", + ccap_root.display() + ); + } + + #[cfg(target_os = "windows")] + { + println!( + "cargo:rerun-if-changed={}/src/ccap_imp_windows.cpp", + ccap_root.display() + ); + println!( + "cargo:rerun-if-changed={}/src/ccap_file_reader_windows.cpp", + ccap_root.display() + ); + } + + // SIMD-specific sources + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + { + println!( + "cargo:rerun-if-changed={}/src/ccap_convert_avx2.cpp", + ccap_root.display() + ); + } + + // Always built in build-from-source mode to provide hasNEON() symbol. + println!( + "cargo:rerun-if-changed={}/src/ccap_convert_neon.cpp", + ccap_root.display() + ); + } + + // Generate bindings + let bindings = bindgen::Builder::default() + .header("wrapper.h") + .clang_arg(format!("-I{}/include", ccap_root.display())) + .parse_callbacks(Box::new(bindgen::CargoCallbacks)) + .allowlist_function("ccap_.*") + .allowlist_type("Ccap.*") + .allowlist_var("CCAP_.*") + .derive_default(true) + .derive_debug(true) + .derive_partialeq(true) + .derive_eq(true) + .generate() + .expect("Unable to generate bindings"); + + // Write the bindings to the $OUT_DIR/bindings.rs file. + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("bindings.rs")) + .expect("Couldn't write bindings!"); +} diff --git a/bindings/rust/build_and_test.sh b/bindings/rust/build_and_test.sh new file mode 100755 index 00000000..9a6c9b3c --- /dev/null +++ b/bindings/rust/build_and_test.sh @@ -0,0 +1,243 @@ +#!/bin/bash + +# Rust bindings build and test script for ccap + +set -e + +jobs_count() { + # Cross-platform parallelism detection. + if command -v nproc >/dev/null 2>&1; then + local n + n="$(nproc 2>/dev/null || true)" + if [[ "$n" =~ ^[0-9]+$ ]] && [[ "$n" -gt 0 ]]; then + echo "$n" + return + fi + fi + + if command -v sysctl >/dev/null 2>&1; then + # macOS / BSD + local n + n="$(sysctl -n hw.ncpu 2>/dev/null || true)" + if [[ "$n" =~ ^[0-9]+$ ]] && [[ "$n" -gt 0 ]]; then + echo "$n" + return + fi + fi + + if command -v getconf >/dev/null 2>&1; then + local n + n="$(getconf _NPROCESSORS_ONLN 2>/dev/null || true)" + if [[ "$n" =~ ^[0-9]+$ ]] && [[ "$n" -gt 0 ]]; then + echo "$n" + return + fi + fi + + echo 4 +} + +detect_cli_devices() { + local cli_bin="" + local original_dir + original_dir="$(pwd)" + + # Prefer Debug build + if [ -x "$PROJECT_ROOT/build/Debug/ccap" ]; then + cli_bin="$PROJECT_ROOT/build/Debug/ccap" + elif [ -x "$PROJECT_ROOT/build/Debug/ccap.exe" ]; then + cli_bin="$PROJECT_ROOT/build/Debug/ccap.exe" + elif [ -x "$PROJECT_ROOT/build/Release/ccap" ]; then + cli_bin="$PROJECT_ROOT/build/Release/ccap" + elif [ -x "$PROJECT_ROOT/build/Release/ccap.exe" ]; then + cli_bin="$PROJECT_ROOT/build/Release/ccap.exe" + else + echo "ccap CLI not found. Building (Debug) with CCAP_BUILD_CLI=ON..." + pushd "$PROJECT_ROOT" >/dev/null + + mkdir -p build/Debug + pushd build/Debug >/dev/null + + # Reconfigure with CLI enabled (idempotent) + cmake ../.. -DCMAKE_BUILD_TYPE=Debug -DCCAP_BUILD_CLI=ON + cmake --build . --config Debug --target ccap-cli -- -j"$(jobs_count)" + + popd >/dev/null + popd >/dev/null + + if [ -x "$PROJECT_ROOT/build/Debug/ccap" ]; then + cli_bin="$PROJECT_ROOT/build/Debug/ccap" + elif [ -x "$PROJECT_ROOT/build/Debug/ccap.exe" ]; then + cli_bin="$PROJECT_ROOT/build/Debug/ccap.exe" + else + cli_bin="$PROJECT_ROOT/build/Debug/ccap" + fi + fi + + cd "$original_dir" + echo "$cli_bin" +} + +parse_cli_device_count() { + local output="$1" + local count=0 + + if [[ "$output" =~ Found[[:space:]]+([0-9]+)[[:space:]]+camera ]]; then + count=${BASH_REMATCH[1]} + elif echo "$output" | grep -qi "No camera devices found"; then + count=0 + else + count=-1 + fi + + echo "$count" +} + +parse_rust_device_count() { + local output="$1" + local count=0 + + if [[ "$output" =~ \#\#[[:space:]]+Found[[:space:]]+([0-9]+)[[:space:]]+video[[:space:]]+capture[[:space:]]+device ]]; then + count=${BASH_REMATCH[1]} + elif echo "$output" | grep -qi "Failed to find any video capture device"; then + count=0 + else + count=-1 + fi + + echo "$count" +} + +echo "ccap Rust Bindings - Build and Test Script" +echo "===========================================" + +# Get the script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RUST_DIR="$SCRIPT_DIR" + +echo "Project root: $PROJECT_ROOT" +echo "Rust bindings: $RUST_DIR" + +# Build the C library first if needed +echo "" +echo "Step 1: Checking ccap C library..." +if [ ! -f "$PROJECT_ROOT/build/Debug/libccap.a" ] && [ ! -f "$PROJECT_ROOT/build/Release/libccap.a" ]; then + echo "ccap C library not found. Building..." + cd "$PROJECT_ROOT" + + mkdir -p build/Debug + cd build/Debug + + if [ ! -f "CMakeCache.txt" ]; then + cmake ../.. -DCMAKE_BUILD_TYPE=Debug + fi + + cmake --build . --config Debug -- -j"$(jobs_count)" + + cd "$RUST_DIR" +else + echo "ccap C library found" +fi + +# Build Rust bindings +echo "" +echo "Step 2: Building Rust bindings..." +cd "$RUST_DIR" + +# Clean previous build +cargo clean + +# Build with default features +echo "Building with default features..." +cargo build + +# Run tests +echo "" +echo "Step 3: Running tests..." +cargo test + +# Build examples +echo "" +echo "Step 4: Building examples..." +cargo build --examples + +echo "" +echo "Step 5: Testing basic functionality (camera discovery vs CLI)..." + +# Run Rust discovery (print_camera) and capture output without aborting +set +e +RUST_DISCOVERY_OUTPUT=$(cargo run --example print_camera 2>&1) +RUST_DISCOVERY_STATUS=$? +set -e + +RUST_DEVICE_COUNT=$(parse_rust_device_count "$RUST_DISCOVERY_OUTPUT") + +CLI_BIN=$(detect_cli_devices) +if [ ! -x "$CLI_BIN" ]; then + echo "❌ Failed to build or locate ccap CLI for reference checks." >&2 + exit 1 +fi + +set +e +CLI_DISCOVERY_OUTPUT=$("$CLI_BIN" --list-devices 2>&1) +CLI_DISCOVERY_STATUS=$? +set -e + +CLI_DEVICE_COUNT=$(parse_cli_device_count "$CLI_DISCOVERY_OUTPUT") + +echo "Rust discovery exit: $RUST_DISCOVERY_STATUS, devices: $RUST_DEVICE_COUNT" +echo "CLI discovery exit: $CLI_DISCOVERY_STATUS, devices: $CLI_DEVICE_COUNT" + +# Decision logic +if [ $CLI_DISCOVERY_STATUS -ne 0 ]; then + echo "❌ CLI discovery failed. Output:" >&2 + echo "$CLI_DISCOVERY_OUTPUT" >&2 + exit 1 +fi + +if [ $CLI_DEVICE_COUNT -lt 0 ]; then + echo "❌ Unable to parse CLI device count. Output:" >&2 + echo "$CLI_DISCOVERY_OUTPUT" >&2 + exit 1 +fi + +if [ $RUST_DISCOVERY_STATUS -ne 0 ] && [ $CLI_DEVICE_COUNT -gt 0 ]; then + echo "❌ Rust discovery failed while CLI sees devices. Output:" >&2 + echo "$RUST_DISCOVERY_OUTPUT" >&2 + exit 1 +fi + +if [ $RUST_DEVICE_COUNT -lt 0 ]; then + echo "⚠️ Rust discovery output could not be parsed. Output:" >&2 + echo "$RUST_DISCOVERY_OUTPUT" >&2 + if [ $CLI_DEVICE_COUNT -gt 0 ]; then + echo "❌ CLI sees devices but Rust discovery is inconclusive." >&2 + exit 1 + fi +fi + +if [ $CLI_DEVICE_COUNT -eq 0 ] && [ $RUST_DEVICE_COUNT -eq 0 ]; then + echo "ℹ️ No cameras detected by CLI or Rust. Skipping capture tests (expected in headless environments)." +elif [ $CLI_DEVICE_COUNT -ne $RUST_DEVICE_COUNT ]; then + echo "❌ Device count mismatch (Rust: $RUST_DEVICE_COUNT, CLI: $CLI_DEVICE_COUNT)." >&2 + echo "Rust output:" >&2 + echo "$RUST_DISCOVERY_OUTPUT" >&2 + echo "CLI output:" >&2 + echo "$CLI_DISCOVERY_OUTPUT" >&2 + exit 1 +else + echo "✅ Camera discovery consistent (devices: $CLI_DEVICE_COUNT)." +fi + +echo "" +echo "✅ All Rust binding builds completed successfully!" +echo "" +echo "Usage examples:" +echo " cargo run --example print_camera" +echo " cargo run --example minimal_example" +echo " cargo run --example capture_grab" +echo " cargo run --example capture_callback" +echo "" +echo "To use in your project, add to Cargo.toml:" +echo ' ccap = { package = "ccap-rs", path = "'$RUST_DIR'" }' diff --git a/bindings/rust/examples/capture_callback.rs b/bindings/rust/examples/capture_callback.rs new file mode 100644 index 00000000..a90213ef --- /dev/null +++ b/bindings/rust/examples/capture_callback.rs @@ -0,0 +1,108 @@ +use ccap::{LogLevel, PixelFormat, PropertyName, Provider, Result, Utils}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +fn main() -> Result<()> { + // Enable verbose log to see debug information + Utils::set_log_level(LogLevel::Verbose); + + // Set error callback to receive error notifications + Provider::set_error_callback(|error_code, description| { + eprintln!( + "Camera Error - Code: {}, Description: {}", + error_code, description + ); + }); + + let temp_provider = Provider::new()?; + let devices = temp_provider.list_devices()?; + if devices.is_empty() { + eprintln!("No camera devices found!"); + return Ok(()); + } + + for (i, device) in devices.iter().enumerate() { + println!("## Found video capture device: {}: {}", i, device); + } + + // Select camera device (automatically use first device for testing) + let device_index = 0; + + // Create provider with selected device + let mut provider = Provider::with_device(device_index)?; + + // Set camera properties + let requested_width = 1920; + let requested_height = 1080; + let requested_fps = 60.0; + + provider.set_property(PropertyName::Width, requested_width as f64)?; + provider.set_property(PropertyName::Height, requested_height as f64)?; + provider.set_property( + PropertyName::PixelFormatOutput, + PixelFormat::Bgra32 as u32 as f64, + )?; + provider.set_property(PropertyName::FrameRate, requested_fps)?; + + // Open and start camera + provider.open()?; + provider.start()?; + + if !provider.is_started() { + eprintln!("Failed to start camera!"); + return Ok(()); + } + + // Get real camera properties + let real_width = provider.get_property(PropertyName::Width)? as i32; + let real_height = provider.get_property(PropertyName::Height)? as i32; + let real_fps = provider.get_property(PropertyName::FrameRate)?; + + println!("Camera started successfully, requested resolution: {}x{}, real resolution: {}x{}, requested fps {}, real fps: {}", + requested_width, requested_height, real_width, real_height, requested_fps, real_fps); + + // Create directory for captures (using std::fs) + std::fs::create_dir_all("./image_capture") + .map_err(|e| ccap::CcapError::FileOperationFailed(e.to_string()))?; + + // Statistics tracking + let frame_count = Arc::new(Mutex::new(0u32)); + let frame_count_clone = frame_count.clone(); + + // Set frame callback + provider.set_new_frame_callback(move |frame| { + let mut count = frame_count_clone.lock().unwrap(); + *count += 1; + + println!( + "VideoFrame {} grabbed: width = {}, height = {}, bytes: {}", + frame.index(), + frame.width(), + frame.height(), + frame.data_size() + ); + + // Try to save frame to directory + if let Ok(filename) = Utils::dump_frame_to_directory(frame, "./image_capture") { + println!("VideoFrame saved to: {}", filename); + } else { + eprintln!("Failed to save frame!"); + } + + true // no need to retain the frame + })?; + + // Wait for 5 seconds to capture frames + println!("Capturing frames for 5 seconds..."); + thread::sleep(Duration::from_secs(5)); + + // Get final count + let final_count = *frame_count.lock().unwrap(); + println!("Captured {} frames, stopping...", final_count); + + // Remove callback before dropping + let _ = provider.remove_new_frame_callback(); + + Ok(()) +} diff --git a/bindings/rust/examples/capture_grab.rs b/bindings/rust/examples/capture_grab.rs new file mode 100644 index 00000000..a5ca5036 --- /dev/null +++ b/bindings/rust/examples/capture_grab.rs @@ -0,0 +1,73 @@ +use ccap::{LogLevel, PropertyName, Provider, Result, Utils}; +use std::fs; + +fn main() -> Result<()> { + // Enable verbose log to see debug information + Utils::set_log_level(LogLevel::Verbose); + + // Set error callback to receive error notifications + Provider::set_error_callback(|error_code, description| { + eprintln!( + "Camera Error - Code: {}, Description: {}", + error_code, description + ); + }); + + // Create a camera provider + let mut provider = Provider::new()?; + + // Open default device + provider.open()?; + provider.start_capture()?; + + if !provider.is_started() { + eprintln!("Failed to start camera!"); + return Ok(()); + } + + // Print the real resolution and fps after camera started + let real_width = provider.get_property(PropertyName::Width)? as u32; + let real_height = provider.get_property(PropertyName::Height)? as u32; + let real_fps = provider.get_property(PropertyName::FrameRate)?; + + println!( + "Camera started successfully, real resolution: {}x{}, real fps: {}", + real_width, real_height, real_fps + ); + + // Create capture directory + let capture_dir = "./image_capture"; + if !std::path::Path::new(capture_dir).exists() { + fs::create_dir_all(capture_dir).map_err(|e| { + ccap::CcapError::InvalidParameter(format!("Failed to create directory: {}", e)) + })?; + } + + // Capture frames (3000 ms timeout when grabbing frames) + let mut frame_count = 0; + while let Some(frame) = provider.grab_frame(3000)? { + let frame_info = frame.info()?; + println!( + "VideoFrame {} grabbed: width = {}, height = {}, bytes: {}", + frame_info.frame_index, frame_info.width, frame_info.height, frame_info.size_in_bytes + ); + + // Save frame to directory + match Utils::dump_frame_to_directory(&frame, capture_dir) { + Ok(dump_file) => { + println!("VideoFrame saved to: {}", dump_file); + } + Err(e) => { + eprintln!("Failed to save frame: {}", e); + } + } + + frame_count += 1; + if frame_count >= 10 { + println!("Captured 10 frames, stopping..."); + break; + } + } + + Ok(()) +} diff --git a/bindings/rust/examples/minimal_example.rs b/bindings/rust/examples/minimal_example.rs new file mode 100644 index 00000000..8547e99f --- /dev/null +++ b/bindings/rust/examples/minimal_example.rs @@ -0,0 +1,61 @@ +use ccap::{CcapError, Provider, Result, Utils}; + +fn main() -> Result<()> { + // Set error callback to receive error notifications + Provider::set_error_callback(|error_code, description| { + eprintln!( + "Error occurred - Code: {}, Description: {}", + error_code, description + ); + }); + + let temp_provider = Provider::new()?; + let devices = temp_provider.list_devices()?; + let camera_index = Utils::select_camera(&devices)?; + + // Use device index instead of name to avoid issues + let camera_index_i32 = i32::try_from(camera_index).map_err(|_| { + CcapError::InvalidParameter(format!( + "camera index {} does not fit into i32", + camera_index + )) + })?; + let mut provider = Provider::with_device(camera_index_i32)?; + provider.open()?; + provider.start()?; + + if !provider.is_started() { + eprintln!("Failed to start camera!"); + return Ok(()); + } + + println!("Camera started successfully."); + + // Capture frames + for i in 0..10 { + match provider.grab_frame(3000) { + Ok(Some(frame)) => { + println!( + "VideoFrame {} grabbed: width = {}, height = {}, bytes: {}, format: {:?}", + frame.index(), + frame.width(), + frame.height(), + frame.data_size(), + frame.pixel_format() + ); + } + Ok(None) => { + eprintln!("Failed to grab frame {}!", i); + return Ok(()); + } + Err(e) => { + eprintln!("Error grabbing frame {}: {}", i, e); + return Ok(()); + } + } + } + + println!("Captured 10 frames, stopping..."); + let _ = provider.stop(); + Ok(()) +} diff --git a/bindings/rust/examples/print_camera.rs b/bindings/rust/examples/print_camera.rs new file mode 100644 index 00000000..d08dbaa2 --- /dev/null +++ b/bindings/rust/examples/print_camera.rs @@ -0,0 +1,83 @@ +use ccap::{LogLevel, Provider, Result, Utils}; + +fn find_camera_names() -> Result> { + // Create a temporary provider to query devices + let provider = Provider::new()?; + let devices = provider.list_devices()?; + + if !devices.is_empty() { + println!("## Found {} video capture device:", devices.len()); + for (index, name) in devices.iter().enumerate() { + println!(" {}: {}", index, name); + } + } else { + eprintln!("Failed to find any video capture device."); + } + + Ok(devices) +} + +fn print_camera_info(device_name: &str) -> Result<()> { + Utils::set_log_level(LogLevel::Verbose); + + // Create provider with specific device name + let provider = match Provider::with_device_name(device_name) { + Ok(p) => p, + Err(e) => { + eprintln!( + "### Failed to create provider for device: {}, error: {}", + device_name, e + ); + return Ok(()); + } + }; + + match provider.device_info() { + Ok(device_info) => { + println!("===== Info for device: {} =======", device_name); + + println!(" Supported resolutions:"); + for resolution in &device_info.supported_resolutions { + println!(" {}x{}", resolution.width, resolution.height); + } + + println!(" Supported pixel formats:"); + for format in &device_info.supported_pixel_formats { + println!(" {}", format.as_str()); + } + + println!("===== Info end =======\n"); + } + Err(e) => { + eprintln!( + "Failed to get device info for: {}, error: {}", + device_name, e + ); + } + } + + Ok(()) +} + +fn main() -> Result<()> { + // Set error callback to receive error notifications + Provider::set_error_callback(|error_code, description| { + eprintln!( + "Camera Error - Code: {}, Description: {}", + error_code, description + ); + }); + + let device_names = find_camera_names()?; + if device_names.is_empty() { + return Ok(()); + } + + for name in &device_names { + if let Err(e) = print_camera_info(name) { + eprintln!("Error processing device {}: {}", name, e); + } + } + + Ok(()) +} diff --git a/bindings/rust/src/convert.rs b/bindings/rust/src/convert.rs new file mode 100644 index 00000000..48613e48 --- /dev/null +++ b/bindings/rust/src/convert.rs @@ -0,0 +1,600 @@ +use crate::error::{CcapError, Result}; +use crate::sys; +use crate::types::ColorConversionBackend; +use std::os::raw::c_int; + +/// Color conversion utilities +pub struct Convert; + +/// Validate that the input buffer has sufficient size +fn validate_buffer_size(data: &[u8], required: usize, name: &str) -> Result<()> { + if data.len() < required { + return Err(CcapError::InvalidParameter(format!( + "{} buffer too small: got {} bytes, need at least {} bytes", + name, + data.len(), + required + ))); + } + Ok(()) +} + +impl Convert { + /// Get current color conversion backend + pub fn backend() -> ColorConversionBackend { + let backend = unsafe { sys::ccap_convert_get_backend() }; + ColorConversionBackend::from_c_enum(backend) + } + + /// Set color conversion backend + pub fn set_backend(backend: ColorConversionBackend) -> Result<()> { + let success = unsafe { sys::ccap_convert_set_backend(backend.to_c_enum()) }; + + if success { + Ok(()) + } else { + Err(CcapError::BackendSetFailed) + } + } + + /// Check if AVX2 is available + pub fn has_avx2() -> bool { + unsafe { sys::ccap_convert_has_avx2() } + } + + /// Check if Apple Accelerate is available + pub fn has_apple_accelerate() -> bool { + unsafe { sys::ccap_convert_has_apple_accelerate() } + } + + /// Check if NEON is available + pub fn has_neon() -> bool { + unsafe { sys::ccap_convert_has_neon() } + } + + /// Convert YUYV to RGB24 + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if `src_data` is too small for the given dimensions. + pub fn yuyv_to_rgb24( + src_data: &[u8], + src_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let required = src_stride * height as usize; + validate_buffer_size(src_data, required, "YUYV source")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_yuyv_to_rgb24( + src_data.as_ptr(), + src_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + sys::CcapConvertFlag_CCAP_CONVERT_FLAG_DEFAULT, + ) + }; + + Ok(dst_data) + } + + /// Convert YUYV to BGR24 + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if `src_data` is too small for the given dimensions. + pub fn yuyv_to_bgr24( + src_data: &[u8], + src_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let required = src_stride * height as usize; + validate_buffer_size(src_data, required, "YUYV source")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_yuyv_to_bgr24( + src_data.as_ptr(), + src_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + sys::CcapConvertFlag_CCAP_CONVERT_FLAG_DEFAULT, + ) + }; + + Ok(dst_data) + } + + /// Convert RGB to BGR + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if `src_data` is too small for the given dimensions. + pub fn rgb_to_bgr( + src_data: &[u8], + src_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let required = src_stride * height as usize; + validate_buffer_size(src_data, required, "RGB source")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_rgb_to_bgr( + src_data.as_ptr(), + src_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + ) + }; + + Ok(dst_data) + } + + /// Convert BGR to RGB + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if `src_data` is too small for the given dimensions. + pub fn bgr_to_rgb( + src_data: &[u8], + src_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let required = src_stride * height as usize; + validate_buffer_size(src_data, required, "BGR source")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_bgr_to_rgb( + src_data.as_ptr(), + src_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + ) + }; + + Ok(dst_data) + } + + /// Convert NV12 to RGB24 + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if buffers are too small for the given dimensions. + pub fn nv12_to_rgb24( + y_data: &[u8], + y_stride: usize, + uv_data: &[u8], + uv_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let y_required = y_stride * height as usize; + let uv_required = uv_stride * ((height as usize + 1) / 2); + validate_buffer_size(y_data, y_required, "NV12 Y plane")?; + validate_buffer_size(uv_data, uv_required, "NV12 UV plane")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_nv12_to_rgb24( + y_data.as_ptr(), + y_stride as c_int, + uv_data.as_ptr(), + uv_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + sys::CcapConvertFlag_CCAP_CONVERT_FLAG_DEFAULT, + ) + }; + + Ok(dst_data) + } + + /// Convert NV12 to BGR24 + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if buffers are too small for the given dimensions. + pub fn nv12_to_bgr24( + y_data: &[u8], + y_stride: usize, + uv_data: &[u8], + uv_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let y_required = y_stride * height as usize; + let uv_required = uv_stride * ((height as usize + 1) / 2); + validate_buffer_size(y_data, y_required, "NV12 Y plane")?; + validate_buffer_size(uv_data, uv_required, "NV12 UV plane")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_nv12_to_bgr24( + y_data.as_ptr(), + y_stride as c_int, + uv_data.as_ptr(), + uv_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + sys::CcapConvertFlag_CCAP_CONVERT_FLAG_DEFAULT, + ) + }; + + Ok(dst_data) + } + + /// Convert I420 to RGB24 + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if buffers are too small for the given dimensions. + #[allow(clippy::too_many_arguments)] + pub fn i420_to_rgb24( + y_data: &[u8], + y_stride: usize, + u_data: &[u8], + u_stride: usize, + v_data: &[u8], + v_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let y_required = y_stride * height as usize; + let uv_height = (height as usize + 1) / 2; + let u_required = u_stride * uv_height; + let v_required = v_stride * uv_height; + validate_buffer_size(y_data, y_required, "I420 Y plane")?; + validate_buffer_size(u_data, u_required, "I420 U plane")?; + validate_buffer_size(v_data, v_required, "I420 V plane")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_i420_to_rgb24( + y_data.as_ptr(), + y_stride as c_int, + u_data.as_ptr(), + u_stride as c_int, + v_data.as_ptr(), + v_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + sys::CcapConvertFlag_CCAP_CONVERT_FLAG_DEFAULT, + ) + }; + + Ok(dst_data) + } + + /// Convert I420 to BGR24 + /// + /// # Errors + /// + /// Returns `CcapError::InvalidParameter` if buffers are too small for the given dimensions. + #[allow(clippy::too_many_arguments)] + pub fn i420_to_bgr24( + y_data: &[u8], + y_stride: usize, + u_data: &[u8], + u_stride: usize, + v_data: &[u8], + v_stride: usize, + width: u32, + height: u32, + ) -> Result> { + let y_required = y_stride * height as usize; + let uv_height = (height as usize + 1) / 2; + let u_required = u_stride * uv_height; + let v_required = v_stride * uv_height; + validate_buffer_size(y_data, y_required, "I420 Y plane")?; + validate_buffer_size(u_data, u_required, "I420 U plane")?; + validate_buffer_size(v_data, v_required, "I420 V plane")?; + + let dst_stride = (width * 3) as usize; + let dst_size = dst_stride * height as usize; + let mut dst_data = vec![0u8; dst_size]; + + unsafe { + sys::ccap_convert_i420_to_bgr24( + y_data.as_ptr(), + y_stride as c_int, + u_data.as_ptr(), + u_stride as c_int, + v_data.as_ptr(), + v_stride as c_int, + dst_data.as_mut_ptr(), + dst_stride as c_int, + width as c_int, + height as c_int, + sys::CcapConvertFlag_CCAP_CONVERT_FLAG_DEFAULT, + ) + }; + + Ok(dst_data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backend_detection() { + // Should be able to get current backend without panic + let backend = Convert::backend(); + println!("Current backend: {:?}", backend); + } + + #[test] + fn test_simd_availability() { + // These should return booleans without panic + let has_avx2 = Convert::has_avx2(); + let has_neon = Convert::has_neon(); + let has_accelerate = Convert::has_apple_accelerate(); + + println!( + "AVX2: {}, NEON: {}, Accelerate: {}", + has_avx2, has_neon, has_accelerate + ); + + // At most one SIMD backend should be available (platform-dependent) + // On x86: AVX2 may be available + // On ARM: NEON may be available + // On macOS: Accelerate may be available + } + + #[test] + fn test_rgb_bgr_conversion() { + let width = 4u32; + let height = 4u32; + let stride = (width * 3) as usize; + + // Create a simple RGB pattern: red, green, blue, white + let mut rgb_data = vec![0u8; stride * height as usize]; + for y in 0..height as usize { + for x in 0..width as usize { + let offset = y * stride + x * 3; + match (x + y) % 4 { + 0 => { + rgb_data[offset] = 255; + rgb_data[offset + 1] = 0; + rgb_data[offset + 2] = 0; + } // Red + 1 => { + rgb_data[offset] = 0; + rgb_data[offset + 1] = 255; + rgb_data[offset + 2] = 0; + } // Green + 2 => { + rgb_data[offset] = 0; + rgb_data[offset + 1] = 0; + rgb_data[offset + 2] = 255; + } // Blue + _ => { + rgb_data[offset] = 255; + rgb_data[offset + 1] = 255; + rgb_data[offset + 2] = 255; + } // White + } + } + } + + // Convert RGB to BGR + let bgr_data = Convert::rgb_to_bgr(&rgb_data, stride, width, height).unwrap(); + assert_eq!(bgr_data.len(), rgb_data.len()); + + // Verify R and B channels are swapped + for y in 0..height as usize { + for x in 0..width as usize { + let offset = y * stride + x * 3; + assert_eq!( + rgb_data[offset], + bgr_data[offset + 2], + "R->B at ({}, {})", + x, + y + ); + assert_eq!( + rgb_data[offset + 1], + bgr_data[offset + 1], + "G==G at ({}, {})", + x, + y + ); + assert_eq!( + rgb_data[offset + 2], + bgr_data[offset], + "B->R at ({}, {})", + x, + y + ); + } + } + + // Convert back: BGR to RGB should restore original + let restored_rgb = Convert::bgr_to_rgb(&bgr_data, stride, width, height).unwrap(); + assert_eq!( + restored_rgb, rgb_data, + "Round-trip RGB->BGR->RGB should be identical" + ); + } + + #[test] + fn test_nv12_to_rgb_basic() { + let width = 16u32; + let height = 16u32; + let y_stride = width as usize; + let uv_stride = width as usize; + + // Create neutral gray NV12 data (Y=128, U=128, V=128 -> gray in RGB) + let y_data = vec![128u8; y_stride * height as usize]; + let uv_data = vec![128u8; uv_stride * (height as usize / 2)]; + + let rgb_data = + Convert::nv12_to_rgb24(&y_data, y_stride, &uv_data, uv_stride, width, height).unwrap(); + + // Verify output size + let expected_size = (width * 3) as usize * height as usize; + assert_eq!(rgb_data.len(), expected_size); + + // All pixels should be approximately gray (128, 128, 128) with some YUV rounding tolerance + for pixel in rgb_data.chunks(3) { + assert!( + pixel[0] >= 100 && pixel[0] <= 156, + "R should be near 128, got {}", + pixel[0] + ); + assert!( + pixel[1] >= 100 && pixel[1] <= 156, + "G should be near 128, got {}", + pixel[1] + ); + assert!( + pixel[2] >= 100 && pixel[2] <= 156, + "B should be near 128, got {}", + pixel[2] + ); + } + } + + #[test] + fn test_nv12_to_bgr_basic() { + let width = 16u32; + let height = 16u32; + let y_stride = width as usize; + let uv_stride = width as usize; + + let y_data = vec![128u8; y_stride * height as usize]; + let uv_data = vec![128u8; uv_stride * (height as usize / 2)]; + + let bgr_data = + Convert::nv12_to_bgr24(&y_data, y_stride, &uv_data, uv_stride, width, height).unwrap(); + + let expected_size = (width * 3) as usize * height as usize; + assert_eq!(bgr_data.len(), expected_size); + } + + #[test] + fn test_i420_to_rgb_basic() { + let width = 16u32; + let height = 16u32; + let y_stride = width as usize; + let u_stride = (width / 2) as usize; + let v_stride = (width / 2) as usize; + + let y_data = vec![128u8; y_stride * height as usize]; + let u_data = vec![128u8; u_stride * (height as usize / 2)]; + let v_data = vec![128u8; v_stride * (height as usize / 2)]; + + let rgb_data = Convert::i420_to_rgb24( + &y_data, y_stride, &u_data, u_stride, &v_data, v_stride, width, height, + ) + .unwrap(); + + let expected_size = (width * 3) as usize * height as usize; + assert_eq!(rgb_data.len(), expected_size); + } + + #[test] + fn test_yuyv_to_rgb_basic() { + let width = 16u32; + let height = 16u32; + let stride = (width * 2) as usize; // YUYV: 2 bytes per pixel + + // Create neutral YUYV data (Y=128, U=128, V=128) + let mut yuyv_data = vec![0u8; stride * height as usize]; + for i in 0..(stride * height as usize / 4) { + yuyv_data[i * 4] = 128; // Y0 + yuyv_data[i * 4 + 1] = 128; // U + yuyv_data[i * 4 + 2] = 128; // Y1 + yuyv_data[i * 4 + 3] = 128; // V + } + + let rgb_data = Convert::yuyv_to_rgb24(&yuyv_data, stride, width, height).unwrap(); + + let expected_size = (width * 3) as usize * height as usize; + assert_eq!(rgb_data.len(), expected_size); + } + + #[test] + fn test_buffer_too_small_error() { + let width = 16u32; + let height = 16u32; + + // Provide a buffer that's too small + let small_buffer = vec![0u8; 10]; + + let result = Convert::yuyv_to_rgb24(&small_buffer, width as usize * 2, width, height); + assert!(result.is_err()); + + if let Err(CcapError::InvalidParameter(msg)) = result { + assert!( + msg.contains("too small"), + "Error message should mention 'too small'" + ); + } else { + panic!("Expected InvalidParameter error"); + } + } + + #[test] + fn test_nv12_buffer_validation() { + let width = 16u32; + let height = 16u32; + let y_stride = width as usize; + let uv_stride = width as usize; + + // Y plane too small + let small_y = vec![0u8; 10]; + let uv_data = vec![128u8; uv_stride * (height as usize / 2)]; + let result = Convert::nv12_to_rgb24(&small_y, y_stride, &uv_data, uv_stride, width, height); + assert!(result.is_err()); + + // UV plane too small + let y_data = vec![128u8; y_stride * height as usize]; + let small_uv = vec![0u8; 10]; + let result = Convert::nv12_to_rgb24(&y_data, y_stride, &small_uv, uv_stride, width, height); + assert!(result.is_err()); + } +} diff --git a/bindings/rust/src/error.rs b/bindings/rust/src/error.rs new file mode 100644 index 00000000..0822331c --- /dev/null +++ b/bindings/rust/src/error.rs @@ -0,0 +1,115 @@ +//! Error handling for ccap library + +use thiserror::Error; + +/// Error types for ccap operations +#[derive(Debug, Error)] +pub enum CcapError { + /// No error occurred + #[error("No error")] + None, + + /// No camera device found + #[error("No camera device found")] + NoDeviceFound, + + /// Invalid device specified + #[error("Invalid device: {0}")] + InvalidDevice(String), + + /// Camera device open failed + #[error("Camera device open failed")] + DeviceOpenFailed, + + /// Device already opened + #[error("Device already opened")] + DeviceAlreadyOpened, + + /// Device not opened + #[error("Device not opened")] + DeviceNotOpened, + + /// Capture start failed + #[error("Capture start failed")] + CaptureStartFailed, + + /// Capture stop failed + #[error("Capture stop failed")] + CaptureStopFailed, + + /// Frame grab failed + #[error("Frame grab failed")] + FrameGrabFailed, + + /// Timeout occurred + #[error("Timeout occurred")] + Timeout, + + /// Invalid parameter + #[error("Invalid parameter: {0}")] + InvalidParameter(String), + + /// Not supported operation + #[error("Operation not supported")] + NotSupported, + + /// Backend set failed + #[error("Backend set failed")] + BackendSetFailed, + + /// String conversion error + #[error("String conversion error: {0}")] + StringConversionError(String), + + /// File operation failed + #[error("File operation failed: {0}")] + FileOperationFailed(String), + + /// Device not found (alias for NoDeviceFound for compatibility) + #[error("Device not found")] + DeviceNotFound, + + /// Internal error + #[error("Internal error: {0}")] + InternalError(String), + + /// Unknown error with error code + #[error("Unknown error: {code}")] + Unknown { + /// Error code from the underlying system + code: i32, + }, +} + +impl From for CcapError { + fn from(code: i32) -> Self { + use crate::sys::*; + + // Convert i32 to CcapErrorCode for matching + // On some platforms CcapErrorCode might be unsigned + let code_u = code as CcapErrorCode; + + #[allow(non_upper_case_globals)] + match code_u { + CcapErrorCode_CCAP_ERROR_NONE => CcapError::None, + CcapErrorCode_CCAP_ERROR_NO_DEVICE_FOUND => CcapError::NoDeviceFound, + CcapErrorCode_CCAP_ERROR_INVALID_DEVICE => CcapError::InvalidDevice("".to_string()), + CcapErrorCode_CCAP_ERROR_DEVICE_OPEN_FAILED => CcapError::DeviceOpenFailed, + CcapErrorCode_CCAP_ERROR_DEVICE_START_FAILED => CcapError::CaptureStartFailed, + CcapErrorCode_CCAP_ERROR_DEVICE_STOP_FAILED => CcapError::CaptureStopFailed, + CcapErrorCode_CCAP_ERROR_FRAME_CAPTURE_FAILED => CcapError::FrameGrabFailed, + CcapErrorCode_CCAP_ERROR_FRAME_CAPTURE_TIMEOUT => CcapError::Timeout, + CcapErrorCode_CCAP_ERROR_UNSUPPORTED_PIXEL_FORMAT => CcapError::NotSupported, + CcapErrorCode_CCAP_ERROR_UNSUPPORTED_RESOLUTION => CcapError::NotSupported, + CcapErrorCode_CCAP_ERROR_PROPERTY_SET_FAILED => { + CcapError::InvalidParameter("".to_string()) + } + CcapErrorCode_CCAP_ERROR_MEMORY_ALLOCATION_FAILED => CcapError::Unknown { code }, + CcapErrorCode_CCAP_ERROR_INTERNAL_ERROR => CcapError::Unknown { code }, + _ => CcapError::Unknown { code }, + } + } +} + +/// Result type for ccap operations +pub type Result = std::result::Result; diff --git a/bindings/rust/src/frame.rs b/bindings/rust/src/frame.rs new file mode 100644 index 00000000..735e84da --- /dev/null +++ b/bindings/rust/src/frame.rs @@ -0,0 +1,245 @@ +use crate::{error::CcapError, sys, types::*}; +use std::ffi::CStr; + +/// Device information structure +#[derive(Debug, Clone)] +pub struct DeviceInfo { + /// Device name + pub name: String, + /// Supported pixel formats + pub supported_pixel_formats: Vec, + /// Supported resolutions + pub supported_resolutions: Vec, +} + +impl DeviceInfo { + /// Create DeviceInfo from C structure + pub fn from_c_struct(info: &sys::CcapDeviceInfo) -> Result { + let name_cstr = unsafe { CStr::from_ptr(info.deviceName.as_ptr()) }; + let name = name_cstr + .to_str() + .map_err(|e| CcapError::StringConversionError(e.to_string()))? + .to_string(); + + // Ensure we don't exceed array bounds + let format_count = (info.pixelFormatCount).min(info.supportedPixelFormats.len()); + let supported_pixel_formats = info.supportedPixelFormats[..format_count] + .iter() + .map(|&format| PixelFormat::from_c_enum(format)) + .collect(); + + let resolution_count = (info.resolutionCount).min(info.supportedResolutions.len()); + let supported_resolutions = info.supportedResolutions[..resolution_count] + .iter() + .map(|&res| Resolution::from(res)) + .collect(); + + Ok(DeviceInfo { + name, + supported_pixel_formats, + supported_resolutions, + }) + } +} + +/// Video frame wrapper +pub struct VideoFrame { + frame: *mut sys::CcapVideoFrame, + owns_frame: bool, // Whether we own the frame and should release it +} + +impl VideoFrame { + pub(crate) fn from_c_ptr(frame: *mut sys::CcapVideoFrame) -> Self { + VideoFrame { + frame, + owns_frame: true, + } + } + + /// Create frame from raw pointer without owning it (for callbacks) + pub(crate) fn from_c_ptr_ref(frame: *mut sys::CcapVideoFrame) -> Self { + VideoFrame { + frame, + owns_frame: false, + } + } + + /// Get the internal C pointer (for internal use) + #[allow(dead_code)] + pub(crate) fn as_c_ptr(&self) -> *const sys::CcapVideoFrame { + self.frame as *const sys::CcapVideoFrame + } + + /// Create frame from raw pointer (for internal use) + #[allow(dead_code)] + pub(crate) fn from_raw(frame: *mut sys::CcapVideoFrame) -> Option { + if frame.is_null() { + None + } else { + Some(VideoFrame { + frame, + owns_frame: true, + }) + } + } + + /// Get frame information + pub fn info<'a>(&'a self) -> crate::error::Result> { + let mut info = sys::CcapVideoFrameInfo::default(); + + let success = unsafe { sys::ccap_video_frame_get_info(self.frame, &mut info) }; + + if success { + // Calculate proper plane sizes based on pixel format + // For plane 0 (Y or main): stride * height + // For chroma planes (UV): stride * height/2 for most formats + let plane0_size = (info.stride[0] as usize) * (info.height as usize); + let plane1_size = if info.stride[1] > 0 { + (info.stride[1] as usize) * ((info.height as usize + 1) / 2) + } else { + 0 + }; + let plane2_size = if info.stride[2] > 0 { + (info.stride[2] as usize) * ((info.height as usize + 1) / 2) + } else { + 0 + }; + + Ok(VideoFrameInfo { + width: info.width, + height: info.height, + pixel_format: PixelFormat::from(info.pixelFormat), + size_in_bytes: info.sizeInBytes, + timestamp: info.timestamp, + frame_index: info.frameIndex, + orientation: FrameOrientation::from(info.orientation), + data_planes: [ + if info.data[0].is_null() { + None + } else { + Some(unsafe { std::slice::from_raw_parts(info.data[0], plane0_size) }) + }, + if info.data[1].is_null() { + None + } else { + Some(unsafe { std::slice::from_raw_parts(info.data[1], plane1_size) }) + }, + if info.data[2].is_null() { + None + } else { + Some(unsafe { std::slice::from_raw_parts(info.data[2], plane2_size) }) + }, + ], + strides: [info.stride[0], info.stride[1], info.stride[2]], + }) + } else { + Err(CcapError::FrameGrabFailed) + } + } + + /// Get all frame data as a slice + pub fn data(&self) -> crate::error::Result<&[u8]> { + let mut info = sys::CcapVideoFrameInfo::default(); + + let success = unsafe { sys::ccap_video_frame_get_info(self.frame, &mut info) }; + + if success && !info.data[0].is_null() { + Ok(unsafe { std::slice::from_raw_parts(info.data[0], info.sizeInBytes as usize) }) + } else { + Err(CcapError::FrameGrabFailed) + } + } + + /// Get frame width (convenience method) + pub fn width(&self) -> u32 { + self.info().map(|info| info.width).unwrap_or(0) + } + + /// Get frame height (convenience method) + pub fn height(&self) -> u32 { + self.info().map(|info| info.height).unwrap_or(0) + } + + /// Get pixel format (convenience method) + pub fn pixel_format(&self) -> PixelFormat { + self.info() + .map(|info| info.pixel_format) + .unwrap_or(PixelFormat::Unknown) + } + + /// Get data size in bytes (convenience method) + pub fn data_size(&self) -> u32 { + self.info().map(|info| info.size_in_bytes).unwrap_or(0) + } + + /// Get frame index (convenience method) + pub fn index(&self) -> u64 { + self.info().map(|info| info.frame_index).unwrap_or(0) + } +} + +impl Drop for VideoFrame { + fn drop(&mut self) { + if self.owns_frame { + unsafe { + sys::ccap_video_frame_release(self.frame); + } + } + } +} + +// # Thread Safety Analysis for VideoFrame +// +// ## Send Implementation +// +// SAFETY: VideoFrame is Send because: +// 1. The frame data pointer is obtained via ccap_video_frame_grab() which returns +// a new frame that is independent of the Provider +// 2. The frame memory is managed by the C library with proper reference counting +// 3. Once created, the frame data is immutable from the Rust side +// 4. The ccap_video_frame_release() function is safe to call from any thread +// +// ## Why NOT Sync +// +// VideoFrame does NOT implement Sync because: +// 1. The underlying C++ VideoFrame object may have internal mutable state +// 2. Concurrent read access from multiple threads is not verified safe +// 3. The C++ library does not document thread-safety guarantees for frame access +// +// ## Usage Guidelines +// +// - Safe: Moving a VideoFrame to another thread (Send) +// - Safe: Dropping a VideoFrame on any thread +// - NOT Safe: Sharing &VideoFrame between threads (no Sync) +// - For multi-threaded access: Clone the frame data to owned Vec first +// +// ## Verification Status +// +// This thread-safety analysis is based on code inspection of the C++ implementation. +// The ccap C++ library does not provide formal thread-safety documentation. +// If you encounter issues with cross-thread frame usage, please report them at: +// https://github.com/wysaid/CameraCapture/issues +unsafe impl Send for VideoFrame {} + +/// High-level video frame information +#[derive(Debug)] +pub struct VideoFrameInfo<'a> { + /// Frame width in pixels + pub width: u32, + /// Frame height in pixels + pub height: u32, + /// Pixel format of the frame + pub pixel_format: PixelFormat, + /// Size of frame data in bytes + pub size_in_bytes: u32, + /// Frame timestamp + pub timestamp: u64, + /// Frame sequence index + pub frame_index: u64, + /// Frame orientation + pub orientation: FrameOrientation, + /// Frame data planes (up to 3 planes) + pub data_planes: [Option<&'a [u8]>; 3], + /// Stride values for each plane + pub strides: [u32; 3], +} diff --git a/bindings/rust/src/lib.rs b/bindings/rust/src/lib.rs new file mode 100644 index 00000000..146a96b8 --- /dev/null +++ b/bindings/rust/src/lib.rs @@ -0,0 +1,37 @@ +//! # ccap - Cross-platform Camera Capture Library +//! +//! A high-performance, lightweight camera capture library with Rust bindings. + +#![warn(missing_docs)] +#![warn(rust_2018_idioms)] + +// Re-export the low-level bindings for advanced users +/// Low-level FFI bindings to ccap C library +pub mod sys { + #![allow(non_upper_case_globals)] + #![allow(non_camel_case_types)] + #![allow(non_snake_case)] + #![allow(dead_code)] + #![allow(missing_docs)] + include!(concat!(env!("OUT_DIR"), "/bindings.rs")); +} + +mod convert; +mod error; +mod frame; +mod provider; +mod types; +mod utils; + +// Public re-exports +pub use convert::Convert; +pub use error::{CcapError, Result}; +pub use frame::*; +pub use provider::Provider; +pub use types::*; +pub use utils::{LogLevel, Utils}; + +/// Get library version string +pub fn version() -> Result { + Provider::version() +} diff --git a/bindings/rust/src/provider.rs b/bindings/rust/src/provider.rs new file mode 100644 index 00000000..15ddeb3d --- /dev/null +++ b/bindings/rust/src/provider.rs @@ -0,0 +1,653 @@ +//! Camera provider for synchronous camera capture operations + +use crate::{error::*, frame::*, sys, types::*}; +use std::ffi::{CStr, CString}; +use std::ptr; +use std::sync::Mutex; + +/// A wrapper around a raw pointer that can be safely shared between threads. +/// This is used for storing callback pointers that we know are safe to share +/// because the callback itself is `Send + Sync`. +struct SendSyncPtr(*mut std::ffi::c_void); + +// SAFETY: The pointer stored here always points to a `Box` +// where `ErrorCallbackBox = Box`. +// The underlying callback is `Send + Sync`, so the pointer is safe to share. +unsafe impl Send for SendSyncPtr {} +unsafe impl Sync for SendSyncPtr {} + +// Global error callback storage - must be at module level to be shared between functions +static GLOBAL_ERROR_CALLBACK: Mutex> = Mutex::new(None); + +/// Type alias for the global error callback +/// +/// # Thread Safety +/// +/// `Provider` implements `Send` to allow moving the provider between threads. +/// However, the underlying C++ implementation is **NOT thread-safe**. +/// +/// **Important**: You must ensure that: +/// - Only one thread accesses the `Provider` at a time +/// - Use `Arc>` or similar synchronization if sharing between threads +/// - If you need to integrate with an async runtime, wrap the `Provider` yourself (e.g. with a mutex and a dedicated worker thread) +/// +/// # Example (Safe Multi-threaded Usage) +/// +/// ```ignore +/// use std::sync::{Arc, Mutex}; +/// use ccap::Provider; +/// +/// let provider = Arc::new(Mutex::new(Provider::new()?)); +/// let provider_clone = Arc::clone(&provider); +/// +/// std::thread::spawn(move || { +/// let mut guard = provider_clone.lock().unwrap(); +/// // Safe: mutex ensures exclusive access +/// guard.grab_frame(1000).ok(); +/// }); +/// ``` +pub struct Provider { + handle: *mut sys::CcapProvider, + is_opened: bool, + callback_ptr: Option<*mut std::ffi::c_void>, +} + +// SAFETY: Provider is Send because: +// 1. The handle is a raw pointer to C++ Provider, which can be safely moved between threads +// 2. The callback_ptr ownership is properly tracked and cleaned up +// 3. We document that users MUST synchronize access externally +// +// WARNING: The underlying C++ Provider is NOT thread-safe. Moving the Provider +// to another thread is safe, but concurrent access from multiple threads is NOT. +// Users must use external synchronization (e.g., Mutex) for multi-threaded access. +unsafe impl Send for Provider {} + +impl Provider { + /// Create a new camera provider + pub fn new() -> Result { + let handle = unsafe { sys::ccap_provider_create() }; + if handle.is_null() { + return Err(CcapError::DeviceOpenFailed); + } + + Ok(Provider { + handle, + is_opened: false, + callback_ptr: None, + }) + } + + /// Create a provider with a specific device index + pub fn with_device(device_index: i32) -> Result { + let handle = unsafe { sys::ccap_provider_create_with_index(device_index, ptr::null()) }; + if handle.is_null() { + return Err(CcapError::InvalidDevice(format!( + "device index {}", + device_index + ))); + } + + Ok(Provider { + handle, + // ccap C API contract: create_with_index opens the device. + // See `include/ccap_c.h`: "Create a camera provider and open device by index". + is_opened: true, + callback_ptr: None, + }) + } + + /// Create a provider with a specific device name + pub fn with_device_name>(device_name: S) -> Result { + let c_name = CString::new(device_name.as_ref()).map_err(|_| { + CcapError::InvalidParameter("device name contains null byte".to_string()) + })?; + + let handle = unsafe { sys::ccap_provider_create_with_device(c_name.as_ptr(), ptr::null()) }; + if handle.is_null() { + return Err(CcapError::InvalidDevice(device_name.as_ref().to_string())); + } + + Ok(Provider { + handle, + // ccap C API contract: create_with_device opens the device. + // See `include/ccap_c.h`: "Create a camera provider and open specified device". + is_opened: true, + callback_ptr: None, + }) + } + + /// Get available camera devices + pub fn get_devices() -> Result> { + // Create a temporary provider to query devices + let provider = Self::new()?; + let mut device_names_list = sys::CcapDeviceNamesList::default(); + + let success = unsafe { + sys::ccap_provider_find_device_names_list(provider.handle, &mut device_names_list) + }; + + if !success { + return Ok(Vec::new()); + } + + let mut devices = Vec::new(); + for i in 0..device_names_list.deviceCount { + let name_bytes = &device_names_list.deviceNames[i]; + let name = unsafe { + let cstr = CStr::from_ptr(name_bytes.as_ptr()); + cstr.to_string_lossy().to_string() + }; + + // Try to get device info by creating provider with this device + if let Ok(device_provider) = Self::with_device_name(&name) { + if let Ok(device_info) = device_provider.get_device_info_direct() { + devices.push(device_info); + } else { + // Fallback: create minimal device info from just the name + devices.push(DeviceInfo { + name, + supported_pixel_formats: Vec::new(), + supported_resolutions: Vec::new(), + }); + } + } + } + + Ok(devices) + } + + /// Get device info directly from current provider + fn get_device_info_direct(&self) -> Result { + let mut device_info = sys::CcapDeviceInfo::default(); + + let success = unsafe { sys::ccap_provider_get_device_info(self.handle, &mut device_info) }; + + if !success { + return Err(CcapError::DeviceOpenFailed); + } + + let name = unsafe { + let cstr = CStr::from_ptr(device_info.deviceName.as_ptr()); + cstr.to_string_lossy().to_string() + }; + + let mut formats = Vec::new(); + for i in 0..device_info.pixelFormatCount { + if i < device_info.supportedPixelFormats.len() { + formats.push(PixelFormat::from(device_info.supportedPixelFormats[i])); + } + } + + let mut resolutions = Vec::new(); + for i in 0..device_info.resolutionCount { + if i < device_info.supportedResolutions.len() { + let res = &device_info.supportedResolutions[i]; + resolutions.push(Resolution { + width: res.width, + height: res.height, + }); + } + } + + Ok(DeviceInfo { + name, + supported_pixel_formats: formats, + supported_resolutions: resolutions, + }) + } + + /// Open the camera device + pub fn open(&mut self) -> Result<()> { + if self.is_opened { + return Ok(()); + } + + let result = unsafe { sys::ccap_provider_open_by_index(self.handle, -1, false) }; + if !result { + return Err(CcapError::DeviceOpenFailed); + } + + self.is_opened = true; + Ok(()) + } + + /// Open device with optional device name and auto start + pub fn open_device(&mut self, device_name: Option<&str>, auto_start: bool) -> Result<()> { + if let Some(name) = device_name { + // Recreate provider with specific device + if !self.handle.is_null() { + // If the previous provider was running, stop it and detach callbacks + // before destroying the underlying handle. + let _ = self.stop_capture(); + let _ = self.remove_new_frame_callback(); + self.cleanup_callback(); + unsafe { + sys::ccap_provider_destroy(self.handle); + } + } + let c_name = CString::new(name).map_err(|_| { + CcapError::InvalidParameter("device name contains null byte".to_string()) + })?; + self.handle = + unsafe { sys::ccap_provider_create_with_device(c_name.as_ptr(), ptr::null()) }; + if self.handle.is_null() { + return Err(CcapError::InvalidDevice(name.to_string())); + } + self.is_opened = true; + } else { + self.open()?; + } + if auto_start { + self.start_capture()?; + } + Ok(()) + } + + /// Get device info for the current provider + pub fn device_info(&self) -> Result { + self.get_device_info_direct() + } + + /// Check if capture is started + pub fn is_started(&self) -> bool { + unsafe { sys::ccap_provider_is_started(self.handle) } + } + + /// Start capture (alias for start_capture) + pub fn start(&mut self) -> Result<()> { + self.start_capture() + } + + /// Stop capture (alias for stop_capture) + pub fn stop(&mut self) -> Result<()> { + self.stop_capture() + } + + /// Check if the camera is opened + pub fn is_opened(&self) -> bool { + self.is_opened + } + + /// Set camera property + pub fn set_property(&mut self, property: PropertyName, value: f64) -> Result<()> { + let property_id: sys::CcapPropertyName = property.into(); + let success = unsafe { sys::ccap_provider_set_property(self.handle, property_id, value) }; + + if !success { + return Err(CcapError::InvalidParameter(format!( + "property {:?}", + property + ))); + } + + Ok(()) + } + + /// Get camera property + pub fn get_property(&self, property: PropertyName) -> Result { + let property_id: sys::CcapPropertyName = property.into(); + let value = unsafe { sys::ccap_provider_get_property(self.handle, property_id) }; + + Ok(value) + } + + /// Set camera resolution + pub fn set_resolution(&mut self, width: u32, height: u32) -> Result<()> { + // Avoid leaving the device in a partially-updated state if only one property update + // succeeds (e.g. width succeeds but height fails). + let (old_w, old_h) = self.resolution()?; + + self.set_property(PropertyName::Width, width as f64)?; + if let Err(e) = self.set_property(PropertyName::Height, height as f64) { + // Best-effort rollback. + let _ = self.set_property(PropertyName::Width, old_w as f64); + let _ = self.set_property(PropertyName::Height, old_h as f64); + return Err(e); + } + + Ok(()) + } + + /// Set camera frame rate + pub fn set_frame_rate(&mut self, fps: f64) -> Result<()> { + self.set_property(PropertyName::FrameRate, fps) + } + + /// Set pixel format + pub fn set_pixel_format(&mut self, format: PixelFormat) -> Result<()> { + self.set_property(PropertyName::PixelFormatOutput, format.to_c_enum() as f64) + } + + /// Grab a single frame with timeout + pub fn grab_frame(&mut self, timeout_ms: u32) -> Result> { + if !self.is_opened { + return Err(CcapError::DeviceNotOpened); + } + + let frame = unsafe { sys::ccap_provider_grab(self.handle, timeout_ms) }; + if frame.is_null() { + return Ok(None); + } + + Ok(Some(VideoFrame::from_c_ptr(frame))) + } + + /// Start continuous capture + pub fn start_capture(&mut self) -> Result<()> { + if !self.is_opened { + return Err(CcapError::DeviceNotOpened); + } + + let result = unsafe { sys::ccap_provider_start(self.handle) }; + if !result { + return Err(CcapError::CaptureStartFailed); + } + + Ok(()) + } + + /// Stop continuous capture + pub fn stop_capture(&mut self) -> Result<()> { + unsafe { sys::ccap_provider_stop(self.handle) }; + Ok(()) + } + + /// Get library version + pub fn version() -> Result { + let version_ptr = unsafe { sys::ccap_get_version() }; + if version_ptr.is_null() { + return Err(CcapError::Unknown { code: -1 }); + } + + let version_cstr = unsafe { CStr::from_ptr(version_ptr) }; + version_cstr + .to_str() + .map(|s| s.to_string()) + .map_err(|_| CcapError::Unknown { code: -2 }) + } + + /// List device names (simple string list) + pub fn list_devices(&self) -> Result> { + let device_infos = Self::get_devices()?; + Ok(device_infos.into_iter().map(|info| info.name).collect()) + } + + /// Find device names (alias for list_devices) + pub fn find_device_names(&self) -> Result> { + self.list_devices() + } + + /// Get current resolution (convenience getter) + pub fn resolution(&self) -> Result<(u32, u32)> { + let width = self.get_property(PropertyName::Width)? as u32; + let height = self.get_property(PropertyName::Height)? as u32; + Ok((width, height)) + } + + /// Get current pixel format (convenience getter) + pub fn pixel_format(&self) -> Result { + let format_val = self.get_property(PropertyName::PixelFormatOutput)? as u32; + Ok(PixelFormat::from_c_enum(format_val as sys::CcapPixelFormat)) + } + + /// Get current frame rate (convenience getter) + pub fn frame_rate(&self) -> Result { + self.get_property(PropertyName::FrameRate) + } + + /// Set error callback for camera errors + /// + /// # Memory Safety + /// + /// This is a **global** callback that persists until replaced or cleared. + /// Calling this function multiple times will properly clean up the previous callback. + /// + /// **Important**: this callback is process-global (shared by all `Provider` instances). + /// The last one set wins. + /// + /// # Thread Safety + /// + /// The callback will be invoked from the camera capture thread. Ensure your + /// callback is thread-safe (`Send + Sync`). + /// + /// # Example + /// + /// ```ignore + /// Provider::set_error_callback(|code, desc| { + /// eprintln!("Camera error {}: {}", code, desc); + /// }); + /// ``` + pub fn set_error_callback(callback: F) + where + F: Fn(i32, &str) + Send + Sync + 'static, + { + use std::os::raw::c_char; + + type ErrorCallbackBox = Box; + + unsafe extern "C" fn error_callback_wrapper( + error_code: sys::CcapErrorCode, + description: *const c_char, + user_data: *mut std::ffi::c_void, + ) { + if user_data.is_null() || description.is_null() { + return; + } + + // SAFETY: user_data points to Box created below + let callback = &**(user_data as *const ErrorCallbackBox); + let desc_cstr = std::ffi::CStr::from_ptr(description); + if let Ok(desc_str) = desc_cstr.to_str() { + callback(error_code as i32, desc_str); + } + } + + // Clean up old callback if exists (use module-level GLOBAL_ERROR_CALLBACK) + if let Ok(mut guard) = GLOBAL_ERROR_CALLBACK.lock() { + if let Some(SendSyncPtr(old_ptr)) = guard.take() { + unsafe { + let _ = Box::from_raw(old_ptr as *mut ErrorCallbackBox); + } + } + + // Store new callback - double box for stable pointer + let callback_box: ErrorCallbackBox = Box::new(callback); + let callback_ptr = Box::into_raw(Box::new(callback_box)); + + unsafe { + sys::ccap_set_error_callback( + Some(error_callback_wrapper), + callback_ptr as *mut std::ffi::c_void, + ); + } + + *guard = Some(SendSyncPtr(callback_ptr as *mut std::ffi::c_void)); + } + } + + /// Set the **global** error callback. + /// + /// This is an alias for [`Provider::set_error_callback`] to make the global scope explicit. + pub fn set_global_error_callback(callback: F) + where + F: Fn(i32, &str) + Send + Sync + 'static, + { + Self::set_error_callback(callback) + } + + /// Clear the global error callback + /// + /// This removes the error callback and frees associated memory. + pub fn clear_error_callback() { + type ErrorCallbackBox = Box; + + // Use module-level GLOBAL_ERROR_CALLBACK (same as set_error_callback) + if let Ok(mut guard) = GLOBAL_ERROR_CALLBACK.lock() { + // Always clear the C-side callback even if we don't have a stored Rust callback. + unsafe { + sys::ccap_set_error_callback(None, ptr::null_mut()); + } + if let Some(SendSyncPtr(old_ptr)) = guard.take() { + unsafe { + let _ = Box::from_raw(old_ptr as *mut ErrorCallbackBox); + } + } + } + } + + /// Clear the **global** error callback. + /// + /// This is an alias for [`Provider::clear_error_callback`] to make the global scope explicit. + pub fn clear_global_error_callback() { + Self::clear_error_callback() + } + + /// Open device with index and auto start + pub fn open_with_index(&mut self, device_index: i32, auto_start: bool) -> Result<()> { + // If the previous provider was running, stop it and detach callbacks + // before destroying the underlying handle. + if !self.handle.is_null() { + let _ = self.stop_capture(); + let _ = self.remove_new_frame_callback(); + self.cleanup_callback(); + unsafe { + sys::ccap_provider_destroy(self.handle); + } + } else { + // Clean up any stale callback allocation even if handle is null. + self.cleanup_callback(); + } + + // Create a new provider with the specified device index + self.handle = unsafe { sys::ccap_provider_create_with_index(device_index, ptr::null()) }; + + if self.handle.is_null() { + return Err(CcapError::InvalidDevice(format!( + "device index {}", + device_index + ))); + } + + // ccap C API contract: create_with_index opens the device. + self.is_opened = true; + if auto_start { + self.start_capture()?; + } + Ok(()) + } + + /// Set a callback for new frame notifications + /// + /// The callback receives a reference to the captured frame and returns `true` + /// to continue capturing or `false` to stop. + /// + /// # Thread Safety + /// + /// The callback will be invoked from the camera capture thread. Ensure your + /// callback is thread-safe (`Send + Sync`). + /// + /// # Example + /// + /// ```ignore + /// provider.set_new_frame_callback(|frame| { + /// println!("Got frame: {}x{}", frame.width(), frame.height()); + /// true // continue capturing + /// })?; + /// ``` + pub fn set_new_frame_callback(&mut self, callback: F) -> Result<()> + where + F: Fn(&VideoFrame) -> bool + Send + Sync + 'static, + { + use std::os::raw::c_void; + + // Type alias for the boxed callback to ensure consistency + type CallbackBox = Box bool + Send + Sync>; + + // Clean up old callback if exists + self.cleanup_callback(); + + unsafe extern "C" fn new_frame_callback_wrapper( + frame: *const sys::CcapVideoFrame, + user_data: *mut c_void, + ) -> bool { + if user_data.is_null() || frame.is_null() { + return false; + } + + // SAFETY: user_data points to a Box that we created below + let callback = &**(user_data as *const CallbackBox); + + // Create a temporary VideoFrame wrapper that doesn't own the frame + let video_frame = VideoFrame::from_c_ptr_ref(frame as *mut sys::CcapVideoFrame); + callback(&video_frame) + } + + // Box the callback as a trait object, then box again to get a thin pointer + // This ensures we can safely convert to/from *mut c_void + let callback_box: CallbackBox = Box::new(callback); + let callback_ptr = Box::into_raw(Box::new(callback_box)); + + let success = unsafe { + sys::ccap_provider_set_new_frame_callback( + self.handle, + Some(new_frame_callback_wrapper), + callback_ptr as *mut c_void, + ) + }; + + if success { + self.callback_ptr = Some(callback_ptr as *mut c_void); + Ok(()) + } else { + // Clean up on failure + unsafe { + let _ = Box::from_raw(callback_ptr); + } + Err(CcapError::InvalidParameter( + "Failed to set frame callback".to_string(), + )) + } + } + + /// Remove frame callback + pub fn remove_new_frame_callback(&mut self) -> Result<()> { + let success = unsafe { + sys::ccap_provider_set_new_frame_callback(self.handle, None, ptr::null_mut()) + }; + + if success { + self.cleanup_callback(); + Ok(()) + } else { + Err(CcapError::CaptureStopFailed) + } + } + + /// Clean up callback pointer + fn cleanup_callback(&mut self) { + // Type alias must match what we used in set_new_frame_callback + type CallbackBox = Box bool + Send + Sync>; + + if let Some(callback_ptr) = self.callback_ptr.take() { + unsafe { + // SAFETY: callback_ptr was created with Box::into_raw(Box::new(callback_box)) + // where callback_box is a CallbackBox + let _ = Box::from_raw(callback_ptr as *mut CallbackBox); + } + } + } +} + +impl Drop for Provider { + fn drop(&mut self) { + // Clean up callback first + self.cleanup_callback(); + + if !self.handle.is_null() { + unsafe { + sys::ccap_provider_destroy(self.handle); + } + self.handle = ptr::null_mut(); + } + } +} diff --git a/bindings/rust/src/types.rs b/bindings/rust/src/types.rs new file mode 100644 index 00000000..1160c3ec --- /dev/null +++ b/bindings/rust/src/types.rs @@ -0,0 +1,226 @@ +use crate::sys; + +/// Pixel format enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PixelFormat { + /// Unknown pixel format + Unknown, + /// NV12 pixel format + Nv12, + /// NV12F pixel format + Nv12F, + /// I420 pixel format + I420, + /// I420F pixel format + I420F, + /// YUYV pixel format + Yuyv, + /// YUYV flipped pixel format + YuyvF, + /// UYVY pixel format + Uyvy, + /// UYVY flipped pixel format + UyvyF, + /// RGB24 pixel format + Rgb24, + /// BGR24 pixel format + Bgr24, + /// RGBA32 pixel format + Rgba32, + /// BGRA32 pixel format + Bgra32, +} + +impl From for PixelFormat { + fn from(format: sys::CcapPixelFormat) -> Self { + match format { + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_UNKNOWN => PixelFormat::Unknown, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_NV12 => PixelFormat::Nv12, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_NV12F => PixelFormat::Nv12F, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_I420 => PixelFormat::I420, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_I420F => PixelFormat::I420F, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_YUYV => PixelFormat::Yuyv, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_YUYV_F => PixelFormat::YuyvF, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_UYVY => PixelFormat::Uyvy, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_UYVY_F => PixelFormat::UyvyF, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_RGB24 => PixelFormat::Rgb24, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_BGR24 => PixelFormat::Bgr24, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_RGBA32 => PixelFormat::Rgba32, + sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_BGRA32 => PixelFormat::Bgra32, + _ => PixelFormat::Unknown, + } + } +} + +impl PixelFormat { + /// Convert pixel format to C enum + pub fn to_c_enum(self) -> sys::CcapPixelFormat { + self.into() + } + + /// Create pixel format from C enum + pub fn from_c_enum(format: sys::CcapPixelFormat) -> Self { + format.into() + } + + /// Get string representation of pixel format + pub fn as_str(self) -> &'static str { + match self { + PixelFormat::Unknown => "Unknown", + PixelFormat::Nv12 => "NV12", + PixelFormat::Nv12F => "NV12F", + PixelFormat::I420 => "I420", + PixelFormat::I420F => "I420F", + PixelFormat::Yuyv => "YUYV", + PixelFormat::YuyvF => "YUYV_F", + PixelFormat::Uyvy => "UYVY", + PixelFormat::UyvyF => "UYVY_F", + PixelFormat::Rgb24 => "RGB24", + PixelFormat::Bgr24 => "BGR24", + PixelFormat::Rgba32 => "RGBA32", + PixelFormat::Bgra32 => "BGRA32", + } + } +} + +impl From for sys::CcapPixelFormat { + fn from(val: PixelFormat) -> Self { + match val { + PixelFormat::Unknown => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_UNKNOWN, + PixelFormat::Nv12 => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_NV12, + PixelFormat::Nv12F => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_NV12F, + PixelFormat::I420 => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_I420, + PixelFormat::I420F => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_I420F, + PixelFormat::Yuyv => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_YUYV, + PixelFormat::YuyvF => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_YUYV_F, + PixelFormat::Uyvy => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_UYVY, + PixelFormat::UyvyF => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_UYVY_F, + PixelFormat::Rgb24 => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_RGB24, + PixelFormat::Bgr24 => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_BGR24, + PixelFormat::Rgba32 => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_RGBA32, + PixelFormat::Bgra32 => sys::CcapPixelFormat_CCAP_PIXEL_FORMAT_BGRA32, + } + } +} + +/// Frame orientation enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameOrientation { + /// Top to bottom orientation + TopToBottom, + /// Bottom to top orientation + BottomToTop, +} + +impl From for FrameOrientation { + fn from(orientation: sys::CcapFrameOrientation) -> Self { + match orientation { + sys::CcapFrameOrientation_CCAP_FRAME_ORIENTATION_TOP_TO_BOTTOM => { + FrameOrientation::TopToBottom + } + sys::CcapFrameOrientation_CCAP_FRAME_ORIENTATION_BOTTOM_TO_TOP => { + FrameOrientation::BottomToTop + } + _ => FrameOrientation::TopToBottom, + } + } +} + +/// Camera property enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropertyName { + /// Width property + Width, + /// Height property + Height, + /// Frame rate property + FrameRate, + /// Internal pixel format property + PixelFormatInternal, + /// Output pixel format property + PixelFormatOutput, + /// Frame orientation property + FrameOrientation, +} + +impl PropertyName { + /// Convert property name to C enum + pub fn to_c_enum(self) -> sys::CcapPropertyName { + self.into() + } +} + +impl From for sys::CcapPropertyName { + fn from(prop: PropertyName) -> Self { + match prop { + PropertyName::Width => sys::CcapPropertyName_CCAP_PROPERTY_WIDTH, + PropertyName::Height => sys::CcapPropertyName_CCAP_PROPERTY_HEIGHT, + PropertyName::FrameRate => sys::CcapPropertyName_CCAP_PROPERTY_FRAME_RATE, + PropertyName::PixelFormatInternal => { + sys::CcapPropertyName_CCAP_PROPERTY_PIXEL_FORMAT_INTERNAL + } + PropertyName::PixelFormatOutput => { + sys::CcapPropertyName_CCAP_PROPERTY_PIXEL_FORMAT_OUTPUT + } + PropertyName::FrameOrientation => sys::CcapPropertyName_CCAP_PROPERTY_FRAME_ORIENTATION, + } + } +} + +/// Color conversion backend enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorConversionBackend { + /// CPU backend + Cpu, + /// AVX2 backend + Avx2, + /// NEON backend + Neon, + /// Apple Accelerate backend + Accelerate, +} + +impl ColorConversionBackend { + /// Convert backend to C enum + pub fn to_c_enum(self) -> sys::CcapConvertBackend { + match self { + ColorConversionBackend::Cpu => sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_CPU, + ColorConversionBackend::Avx2 => sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_AVX2, + ColorConversionBackend::Neon => sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_NEON, + ColorConversionBackend::Accelerate => { + sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_APPLE_ACCELERATE + } + } + } + + /// Create backend from C enum + pub fn from_c_enum(backend: sys::CcapConvertBackend) -> Self { + match backend { + sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_CPU => ColorConversionBackend::Cpu, + sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_AVX2 => ColorConversionBackend::Avx2, + sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_NEON => ColorConversionBackend::Neon, + sys::CcapConvertBackend_CCAP_CONVERT_BACKEND_APPLE_ACCELERATE => { + ColorConversionBackend::Accelerate + } + _ => ColorConversionBackend::Cpu, + } + } +} + +/// Resolution structure +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Resolution { + /// Width in pixels + pub width: u32, + /// Height in pixels + pub height: u32, +} + +impl From for Resolution { + fn from(res: sys::CcapResolution) -> Self { + Resolution { + width: res.width, + height: res.height, + } + } +} diff --git a/bindings/rust/src/utils.rs b/bindings/rust/src/utils.rs new file mode 100644 index 00000000..810b693a --- /dev/null +++ b/bindings/rust/src/utils.rs @@ -0,0 +1,276 @@ +use crate::error::{CcapError, Result}; +use crate::frame::VideoFrame; +use crate::sys; +use crate::types::PixelFormat; +use std::ffi::CString; +use std::path::Path; + +/// Utility functions +pub struct Utils; + +impl Utils { + /// Convert pixel format enum to string + pub fn pixel_format_to_string(format: PixelFormat) -> Result { + let mut buffer = [0i8; 64]; + let result = unsafe { + sys::ccap_pixel_format_to_string(format.to_c_enum(), buffer.as_mut_ptr(), buffer.len()) + }; + + if result < 0 { + return Err(CcapError::StringConversionError( + "Unknown pixel format".to_string(), + )); + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(buffer.as_ptr()) }; + c_str.to_str().map(|s| s.to_string()).map_err(|_| { + CcapError::StringConversionError("Invalid pixel format string".to_string()) + }) + } + + /// Convert string to pixel format enum + pub fn string_to_pixel_format(format_str: &str) -> Result { + // This function doesn't exist in C API, we'll implement a simple mapping + match format_str.to_lowercase().as_str() { + "unknown" => Ok(PixelFormat::Unknown), + "nv12" => Ok(PixelFormat::Nv12), + "nv12f" => Ok(PixelFormat::Nv12F), + "i420" => Ok(PixelFormat::I420), + "i420f" => Ok(PixelFormat::I420F), + "yuyv" => Ok(PixelFormat::Yuyv), + "yuyvf" => Ok(PixelFormat::YuyvF), + "uyvy" => Ok(PixelFormat::Uyvy), + "uyvyf" => Ok(PixelFormat::UyvyF), + "rgb24" => Ok(PixelFormat::Rgb24), + "bgr24" => Ok(PixelFormat::Bgr24), + "rgba32" => Ok(PixelFormat::Rgba32), + "bgra32" => Ok(PixelFormat::Bgra32), + _ => Err(CcapError::StringConversionError( + "Unknown pixel format string".to_string(), + )), + } + } + + /// Save frame as BMP file + pub fn save_frame_as_bmp>(frame: &VideoFrame, file_path: P) -> Result<()> { + // This function doesn't exist in C API, we'll use the dump_frame_to_file instead + Self::dump_frame_to_file(frame, file_path)?; + Ok(()) + } + + /// Convert path to C string safely, handling Windows-specific path issues + fn path_to_cstring>(path: P) -> Result { + #[cfg(windows)] + { + // On Windows, handle potential UTF-16 to UTF-8 conversion issues + let path_str = path.as_ref().to_string_lossy(); + CString::new(path_str.as_bytes()) + .map_err(|_| CcapError::StringConversionError("Invalid file path".to_string())) + } + + #[cfg(not(windows))] + { + // On Unix-like systems, standard conversion should work + let path_str = path + .as_ref() + .to_str() + .ok_or_else(|| CcapError::StringConversionError("Invalid file path".to_string()))?; + CString::new(path_str) + .map_err(|_| CcapError::StringConversionError("Invalid file path".to_string())) + } + } + + /// Save a video frame to a file with automatic format detection + pub fn dump_frame_to_file>( + frame: &VideoFrame, + filename_no_suffix: P, + ) -> Result { + let c_path = Self::path_to_cstring(filename_no_suffix)?; + + // First call to get required buffer size + let buffer_size = unsafe { + sys::ccap_dump_frame_to_file(frame.as_c_ptr(), c_path.as_ptr(), std::ptr::null_mut(), 0) + }; + + if buffer_size <= 0 { + return Err(CcapError::FileOperationFailed( + "Failed to dump frame to file".to_string(), + )); + } + + // Second call to get actual result + let mut buffer = vec![0u8; buffer_size as usize]; + let result_len = unsafe { + sys::ccap_dump_frame_to_file( + frame.as_c_ptr(), + c_path.as_ptr(), + buffer.as_mut_ptr() as *mut i8, + buffer.len(), + ) + }; + + if result_len <= 0 { + return Err(CcapError::FileOperationFailed( + "Failed to dump frame to file".to_string(), + )); + } + + // Convert to string + buffer.truncate(result_len as usize); + String::from_utf8(buffer) + .map_err(|_| CcapError::StringConversionError("Invalid output path string".to_string())) + } + + /// Save a video frame to directory with auto-generated filename + pub fn dump_frame_to_directory>( + frame: &VideoFrame, + directory: P, + ) -> Result { + let c_dir = Self::path_to_cstring(directory)?; + + // First call to get required buffer size + let buffer_size = unsafe { + sys::ccap_dump_frame_to_directory( + frame.as_c_ptr(), + c_dir.as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + + if buffer_size <= 0 { + return Err(CcapError::FileOperationFailed( + "Failed to dump frame to directory".to_string(), + )); + } + + // Second call to get actual result + let mut buffer = vec![0u8; buffer_size as usize]; + let result_len = unsafe { + sys::ccap_dump_frame_to_directory( + frame.as_c_ptr(), + c_dir.as_ptr(), + buffer.as_mut_ptr() as *mut i8, + buffer.len(), + ) + }; + + if result_len <= 0 { + return Err(CcapError::FileOperationFailed( + "Failed to dump frame to directory".to_string(), + )); + } + + // Convert to string + buffer.truncate(result_len as usize); + String::from_utf8(buffer) + .map_err(|_| CcapError::StringConversionError("Invalid output path string".to_string())) + } + + /// Save RGB data as BMP file (generic version) + #[allow(clippy::too_many_arguments)] + pub fn save_rgb_data_as_bmp>( + filename: P, + data: &[u8], + width: u32, + stride: u32, + height: u32, + is_bgr: bool, + has_alpha: bool, + is_top_to_bottom: bool, + ) -> Result<()> { + let c_path = Self::path_to_cstring(filename)?; + + let success = unsafe { + sys::ccap_save_rgb_data_as_bmp( + c_path.as_ptr(), + data.as_ptr(), + width, + stride, + height, + is_bgr, + has_alpha, + is_top_to_bottom, + ) + }; + + if success { + Ok(()) + } else { + Err(CcapError::FileOperationFailed( + "Failed to save RGB data as BMP".to_string(), + )) + } + } + + /// Interactive camera selection helper + pub fn select_camera(devices: &[String]) -> Result { + if devices.is_empty() { + return Err(CcapError::DeviceNotFound); + } + + if devices.len() == 1 { + println!("Using the only available device: {}", devices[0]); + return Ok(0); + } + + println!("Multiple devices found, please select one:"); + for (i, device) in devices.iter().enumerate() { + println!(" {}: {}", i, device); + } + + print!("Enter the index of the device you want to use: "); + use std::io::{self, Write}; + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .map_err(|e| CcapError::InvalidParameter(format!("Failed to read input: {}", e)))?; + + let selected_index = input.trim().parse::().unwrap_or(0); + + if selected_index >= devices.len() { + println!("Invalid index, using the first device: {}", devices[0]); + Ok(0) + } else { + println!("Using device: {}", devices[selected_index]); + Ok(selected_index) + } + } + + /// Set log level + pub fn set_log_level(level: LogLevel) { + unsafe { + sys::ccap_set_log_level(level.to_c_enum()); + } + } +} + +/// Log level enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + /// No log output + None, + /// Error log level + Error, + /// Warning log level + Warning, + /// Info log level + Info, + /// Verbose log level + Verbose, +} + +impl LogLevel { + /// Convert log level to C enum + pub fn to_c_enum(self) -> sys::CcapLogLevel { + match self { + LogLevel::None => sys::CcapLogLevel_CCAP_LOG_LEVEL_NONE, + LogLevel::Error => sys::CcapLogLevel_CCAP_LOG_LEVEL_ERROR, + LogLevel::Warning => sys::CcapLogLevel_CCAP_LOG_LEVEL_WARNING, + LogLevel::Info => sys::CcapLogLevel_CCAP_LOG_LEVEL_INFO, + LogLevel::Verbose => sys::CcapLogLevel_CCAP_LOG_LEVEL_VERBOSE, + } + } +} diff --git a/bindings/rust/tests/integration_tests.rs b/bindings/rust/tests/integration_tests.rs new file mode 100644 index 00000000..4336575b --- /dev/null +++ b/bindings/rust/tests/integration_tests.rs @@ -0,0 +1,90 @@ +//! Integration tests for ccap rust bindings +//! +//! Tests the main API functionality + +use ccap::{CcapError, PixelFormat, Provider, Result}; + +fn skip_camera_tests() -> bool { + std::env::var("CCAP_SKIP_CAMERA_TESTS").is_ok() +} + +#[test] +fn test_provider_creation() -> Result<()> { + let provider = Provider::new()?; + assert!(!provider.is_opened()); + Ok(()) +} + +#[test] +fn test_library_version() -> Result<()> { + let version = ccap::version()?; + assert!(!version.is_empty()); + assert!(version.contains('.')); + println!("ccap version: {}", version); + Ok(()) +} + +#[test] +fn test_device_listing() -> Result<()> { + if skip_camera_tests() { + eprintln!("Skipping device_listing due to CCAP_SKIP_CAMERA_TESTS"); + return Ok(()); + } + let provider = Provider::new()?; + let devices = provider.list_devices()?; + // In test environment we might not have cameras, so just check it doesn't crash + println!("Found {} devices", devices.len()); + for (i, device) in devices.iter().enumerate() { + println!("Device {}: {}", i, device); + } + Ok(()) +} + +#[test] +fn test_pixel_format_conversion() { + let format = PixelFormat::Rgb24; + let c_format = format.to_c_enum(); + let format_back = PixelFormat::from_c_enum(c_format); + assert_eq!(format, format_back); +} + +#[test] +fn test_error_types() { + let error = CcapError::NoDeviceFound; + let error_str = format!("{}", error); + assert!(error_str.contains("No camera device found")); +} + +#[test] +fn test_provider_with_index() { + if skip_camera_tests() { + eprintln!("Skipping provider_with_index due to CCAP_SKIP_CAMERA_TESTS"); + return; + } + // This might fail if no device at index 0, but should not crash + match Provider::with_device(0) { + Ok(_provider) => { + println!("Successfully created provider with device 0"); + } + Err(e) => { + println!("Expected error for device 0: {}", e); + } + } +} + +#[test] +fn test_device_operations_without_camera() { + if skip_camera_tests() { + eprintln!("Skipping device_operations_without_camera due to CCAP_SKIP_CAMERA_TESTS"); + return; + } + // Test that operations work regardless of camera presence + let provider = Provider::new().expect("Failed to create provider"); + + // These should work with or without cameras + let devices = provider.list_devices().expect("Failed to list devices"); + println!("Found {} device(s)", devices.len()); + + let version = Provider::version().expect("Failed to get version"); + assert!(!version.is_empty()); +} diff --git a/bindings/rust/wrapper.h b/bindings/rust/wrapper.h new file mode 100644 index 00000000..835170c4 --- /dev/null +++ b/bindings/rust/wrapper.h @@ -0,0 +1,3 @@ +#include "ccap_c.h" +#include "ccap_utils_c.h" +#include "ccap_convert_c.h" diff --git a/docs/content/cli.md b/docs/content/cli.md index 7c476d17..d5604e07 100644 --- a/docs/content/cli.md +++ b/docs/content/cli.md @@ -518,4 +518,5 @@ ccap -d 0 --format xyz - [Main ccap Documentation](documentation.md) - Overview of the CameraCapture library - [CMake Build Options](cmake-options.md) - Build configuration details - [C Interface Documentation](c-interface.md) - C API reference +- [Rust Bindings](rust-bindings.md) - Rust crate usage and build notes - [Examples](https://github.com/wysaid/CameraCapture/tree/main/examples) - Code examples using the library diff --git a/docs/content/cli.zh.md b/docs/content/cli.zh.md index 1779e8e3..a73eeb7f 100644 --- a/docs/content/cli.zh.md +++ b/docs/content/cli.zh.md @@ -482,4 +482,5 @@ ccap -d 0 --format xyz - [主 ccap 文档](documentation.zh.md) - CameraCapture 库概述 - [CMake 构建选项](cmake-options.zh.md) - 构建配置详情 - [C 接口文档](c-interface.md) - C API 参考 +- [Rust 绑定](rust-bindings.zh.md) - Rust crate 使用与构建说明 - [示例](https://github.com/wysaid/CameraCapture/tree/main/examples) - 使用库的代码示例 diff --git a/docs/content/cmake-options.md b/docs/content/cmake-options.md index 363b392a..6079d5df 100644 --- a/docs/content/cmake-options.md +++ b/docs/content/cmake-options.md @@ -408,6 +408,7 @@ See `cmake/dev.cmake.example` for more examples. - [Build and Install Guide](https://github.com/wysaid/CameraCapture/blob/main/BUILD_AND_INSTALL.md) - [CLI Tool Documentation](cli.md) - [C Interface Documentation](c-interface.md) +- [Rust Bindings](rust-bindings.md) ## Quick Reference Table diff --git a/docs/content/cmake-options.zh.md b/docs/content/cmake-options.zh.md index 8288dea7..d8cd7269 100644 --- a/docs/content/cmake-options.zh.md +++ b/docs/content/cmake-options.zh.md @@ -267,6 +267,7 @@ set(CCAP_BUILD_CLI ON CACHE BOOL "" FORCE) ## 需要帮助? - 📖 [完整文档](documentation.zh.md) +- 🦀 [Rust 绑定](rust-bindings.zh.md) - 🐛 [报告问题](https://github.com/wysaid/CameraCapture/issues) - 💬 [讨论](https://github.com/wysaid/CameraCapture/discussions) - 📧 邮箱:wysaid@gmail.com diff --git a/docs/content/documentation.md b/docs/content/documentation.md index cb7c907d..a6c95b3a 100644 --- a/docs/content/documentation.md +++ b/docs/content/documentation.md @@ -10,7 +10,7 @@ - Dual API: Modern C++17 and pure C99 - Video file playback (Windows & macOS) - play MP4, AVI, MOV, MKV and other formats - Command-line tool for scripting, automation, and video processing -- **Also available:** [C Interface](c-interface.md) for language bindings +- **Language bindings:** [C Interface](c-interface.md) and [Rust Bindings](rust-bindings.md) ## Installation diff --git a/docs/content/documentation.zh.md b/docs/content/documentation.zh.md index c1183e68..8038a656 100644 --- a/docs/content/documentation.zh.md +++ b/docs/content/documentation.zh.md @@ -10,7 +10,7 @@ - 双 API:现代 C++17 和纯 C99 - 视频文件播放(Windows & macOS)- 播放 MP4、AVI、MOV、MKV 等格式 - 用于脚本、自动化和视频处理的命令行工具 -- **同时提供:**[C 接口](c-interface.zh.md)用于语言绑定 +- **语言绑定:**[C 接口](c-interface.zh.md) 与 [Rust 绑定](rust-bindings.zh.md) ## 安装 diff --git a/docs/content/implementation-details.md b/docs/content/implementation-details.md index cdd05bf2..a4efbb50 100644 --- a/docs/content/implementation-details.md +++ b/docs/content/implementation-details.md @@ -405,6 +405,7 @@ From `.github/copilot-instructions.md`: - 📖 [Main Documentation](documentation.md) - 📖 [CLI Tool Guide](cli.md) +- 🦀 [Rust Bindings](rust-bindings.md) - 🐛 [Report Issues](https://github.com/wysaid/CameraCapture/issues) - 💬 [Discussions](https://github.com/wysaid/CameraCapture/discussions) - 📧 Email: wysaid@gmail.com diff --git a/docs/content/implementation-details.zh.md b/docs/content/implementation-details.zh.md index 1b810843..e3440a59 100644 --- a/docs/content/implementation-details.zh.md +++ b/docs/content/implementation-details.zh.md @@ -300,6 +300,7 @@ ls -l /dev/video* - 📖 [主文档](documentation.zh.md) - 📖 [CLI 工具指南](cli.zh.md) +- 🦀 [Rust 绑定](rust-bindings.zh.md) - 🐛 [报告问题](https://github.com/wysaid/CameraCapture/issues) - 💬 [讨论](https://github.com/wysaid/CameraCapture/discussions) - 📧 邮箱:wysaid@gmail.com diff --git a/docs/content/rust-bindings.md b/docs/content/rust-bindings.md new file mode 100644 index 00000000..5bd12c08 --- /dev/null +++ b/docs/content/rust-bindings.md @@ -0,0 +1,89 @@ +# Rust Bindings + +ccap provides first-class Rust bindings via the `ccap-rs` crate. + +- Crate: [ccap-rs on crates.io](https://crates.io/crates/ccap-rs) +- API docs: [docs.rs/ccap-rs](https://docs.rs/ccap-rs) +- Source in this repo: `bindings/rust/` + +> Note: The published crate name is `ccap-rs`, but the library name is `ccap` so you can write `use ccap::Provider;`. + +## Quick Start + +### Add dependency + +```bash +cargo add ccap-rs +``` + +If you prefer using `ccap` as the crate name in code (recommended): + +```toml +[dependencies] +ccap = { package = "ccap-rs", version = "" } +``` + +### Minimal example + +```rust +use ccap::{Provider, Result}; + +fn main() -> Result<()> { + let mut provider = Provider::new()?; + let devices = provider.find_device_names()?; + + if let Some(device) = devices.first() { + provider.open_device(Some(device), true)?; + if let Some(frame) = provider.grab_frame(3000)? { + let info = frame.info()?; + println!("{}x{} {:?}", info.width, info.height, info.pixel_format); + } + } + + Ok(()) +} +``` + +## Feature flags + +- `build-source` (default): build the underlying C/C++ sources as part of `cargo build`. + - Best for crates.io users. +- `static-link`: link against a pre-built static library from a CameraCapture checkout. + - Best for development when you are working on both C/C++ and Rust. + - If the build script cannot find the repo root automatically, set `CCAP_SOURCE_DIR`. + +## Build notes + +### Using `build-source` (default) + +This is the recommended mode for typical users. + +- Requirements: Rust 1.65+, CMake 3.14+. +- The crate builds the C/C++ sources during compilation. + +### Using `static-link` + +This mode expects a pre-built `ccap` static library under a CameraCapture checkout: + +- Build the C/C++ project first (Debug or Release). +- Then build your Rust project with the `static-link` feature. + +If needed, set: + +- `CCAP_SOURCE_DIR=/path/to/CameraCapture` + +## Platform support + +Camera capture backends: + +- Windows: DirectShow +- macOS/iOS: AVFoundation +- Linux: V4L2 + +Video file playback support depends on the underlying C/C++ backend (currently Windows/macOS only). + +## See also + +- [Main ccap Documentation](documentation.md) (English) / [主文档](documentation.zh.md) (中文) +- [C Interface Documentation](c-interface.md) (English) / [C 接口文档](c-interface.zh.md) (中文) +- [CLI Tool Guide](cli.md) (English) / [CLI 工具指南](cli.zh.md) (中文) diff --git a/docs/content/rust-bindings.zh.md b/docs/content/rust-bindings.zh.md new file mode 100644 index 00000000..2bc46651 --- /dev/null +++ b/docs/content/rust-bindings.zh.md @@ -0,0 +1,90 @@ +# Rust 绑定 + +ccap 提供了完善的 Rust bindings,对应 crates.io 上的 `ccap-rs` crate。 + +- Crate: [ccap-rs](https://crates.io/crates/ccap-rs) +- API 文档: [docs.rs/ccap-rs](https://docs.rs/ccap-rs) +- 仓库内源码:`bindings/rust/` + +> 说明:发布到 crates.io 的 crate 名称是 `ccap-rs`,但库名保持为 `ccap`,因此你可以直接 `use ccap::Provider;`。 + +## 快速开始 + +### 添加依赖 + +```bash +cargo add ccap-rs +``` + +如果你希望在代码中使用 `ccap` 作为 crate 名称(推荐): + +```toml +[dependencies] +ccap = { package = "ccap-rs", version = "" } +``` + +### 最小示例 + +```rust +use ccap::{Provider, Result}; + +fn main() -> Result<()> { + let mut provider = Provider::new()?; + let devices = provider.find_device_names()?; + + if let Some(device) = devices.first() { + provider.open_device(Some(device), true)?; + if let Some(frame) = provider.grab_frame(3000)? { + let info = frame.info()?; + println!("{}x{} {:?}", info.width, info.height, info.pixel_format); + } + } + + Ok(()) +} +``` + +## Feature flags + +- `build-source`(默认):在 `cargo build` 时自动编译底层 C/C++ 源码。 + - 更适合 crates.io 用户。 +- `static-link`:链接到 CameraCapture 源码仓库下预先编译好的静态库。 + - 更适合开发阶段(同时改 C/C++ 与 Rust)。 + - 若构建脚本无法自动定位仓库根目录,可设置 `CCAP_SOURCE_DIR`。 + + +## 构建说明 + +### 使用 `build-source`(默认) + +这是普通用户的推荐模式。 + +- 依赖:Rust 1.65+、CMake 3.14+。 +- crate 会在编译时构建底层 C/C++ 源码。 + +### 使用 `static-link` + +该模式要求你在 CameraCapture 源码仓库中先编译出 `ccap` 静态库: + +- 先构建 C/C++ 项目(Debug 或 Release)。 +- 再在 Rust 项目中启用 `static-link` feature 进行构建。 + +必要时设置: + +- `CCAP_SOURCE_DIR=/path/to/CameraCapture` + +## 平台支持 + +相机捕获后端: + +- Windows:DirectShow +- macOS/iOS:AVFoundation +- Linux:V4L2 + +视频文件播放是否可用取决于底层 C/C++ 后端(目前 Windows/macOS 支持,Linux 暂不支持)。 + +## 另请参阅 + +- [主文档](documentation.zh.md) +- [C 接口文档](c-interface.zh.md) +- [CLI 工具指南](cli.zh.md) diff --git a/docs/documentation.html b/docs/documentation.html index 80802ab0..89faa345 100644 --- a/docs/documentation.html +++ b/docs/documentation.html @@ -15,6 +15,9 @@ + + + @@ -35,6 +38,7 @@
  • Implementation Details实现细节
  • Memory & Video Playback内存管理与视频播放
  • CMake Build OptionsCMake 构建选项
  • +
  • Rust BindingsRust 绑定
  • CLI ToolCLI 工具
  • @@ -77,6 +81,7 @@

    文档

  • Main Documentation主文档
  • CLI ToolCLI 工具
  • C InterfaceC 接口
  • +
  • Rust BindingsRust 绑定
  • Implementation Details实现细节
  • Memory Management内存管理
  • CMake OptionsCMake 选项
  • @@ -215,6 +220,13 @@

    许可证

    if (docName) { // Remove any existing language suffix from docName var baseDocName = docName.endsWith('.zh') ? docName.replace('.zh', '') : docName; + + // Sanitize to prevent path traversal / unexpected fetches. + // Allowed doc IDs: letters, numbers and hyphens (e.g. c-interface, rust-bindings). + baseDocName = baseDocName.replace(/[^a-z0-9-]/gi, ''); + if (!baseDocName) { + baseDocName = 'documentation'; + } // Load document based on current interface language, not URL suffix if (lang === 'zh') { diff --git a/docs/index.html b/docs/index.html index 1f8413e7..4d9f1eb3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,8 +4,8 @@ ccap - Cross-Platform Camera Capture Library - - + + @@ -13,6 +13,7 @@ + @@ -32,6 +33,7 @@
  • Implementation Details实现细节
  • Memory & Video Playback内存管理与视频播放
  • CMake Build OptionsCMake 构建选项
  • +
  • Rust BindingsRust 绑定
  • CLI ToolCLI 工具
  • @@ -44,8 +46,8 @@

    (C)amera(CAP)ture

    -

    A lightweight, high-performance cross-platform camera capture library with video file playback support (Windows/macOS)

    -

    轻量级、高性能的跨平台相机捕获库,支持视频文件播放(Windows/macOS)

    +

    A lightweight, high-performance cross-platform camera capture library with video file playback support (Windows/macOS), plus Rust bindings.

    +

    轻量级、高性能的跨平台相机捕获库,支持视频文件播放(Windows/macOS),并提供 Rust bindings。

    Hardware-accelerated conversion with AVX2, Apple Accelerate, NEON

    支持 AVX2、Apple Accelerate、NEON 硬件加速

    @@ -55,6 +57,9 @@

    (C)amera(CAP)ture

    C++17 C99 + + Rust bindings +
    @@ -190,6 +195,7 @@

    快速开始

    +
    @@ -246,6 +252,32 @@

    快速开始

    ccap_provider_destroy(provider); return 0; +} +
    + +
    +
    // Rust Example
    +// Cargo.toml:
    +// ccap = { package = "ccap-rs", version = "<latest>" }
    +
    +use ccap::{Provider, Result};
    +
    +fn main() -> Result<()> {
    +    let mut provider = Provider::new()?;
    +
    +    let devices = provider.find_device_names()?;
    +    println!("Found {} camera(s)", devices.len());
    +
    +    if let Some(first) = devices.get(0) {
    +        provider.open_device(Some(first), true)?;
    +
    +        if let Some(frame) = provider.grab_frame(3000)? {
    +            let info = frame.info()?;
    +            println!("Captured: {}x{}", info.width, info.height);
    +        }
    +    }
    +
    +    Ok(())
     }
    @@ -282,6 +314,14 @@

    Homebrew (macOS)

    brew tap wysaid/ccap
     brew install ccap
    + +
    +

    Rust (crates.io)

    +

    Rust(crates.io)

    +
    cargo add ccap-rs
    +# Recommended in Cargo.toml:
    +# ccap = { package = "ccap-rs", version = "<latest>" }
    +

    CMake Integration

    @@ -356,6 +396,7 @@

    文档

  • Getting Started快速开始
  • CLI ToolCLI 工具
  • C InterfaceC 接口
  • +
  • Rust BindingsRust 绑定
  • Implementation Details实现细节
  • Memory Management内存管理
  • CMake OptionsCMake 选项
  • diff --git a/scripts/README.md b/scripts/README.md index 544e83b5..254fce38 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -72,6 +72,32 @@ This script detects the current system and compiler architecture support, helpin This script specifically verifies compile-time and runtime detection of NEON instruction set, demonstrating ccap's NEON support status on different architectures. +- **`test_remote_crate.sh`** - Remote crate verification for ccap-rs (Rust bindings) + +```bash +# Test latest published version from crates.io +/path/to/ccap/scripts/test_remote_crate.sh + +# Test specific version +/path/to/ccap/scripts/test_remote_crate.sh 1.5.0-test.20260111124314.21e5358 + +# Enable debug mode to see detailed information +CCAP_TEST_DEBUG=1 /path/to/ccap/scripts/test_remote_crate.sh +``` + +This script downloads and tests the ccap-rs package from crates.io in an isolated environment, verifying: +- Provider API (camera device enumeration) +- Camera capture (if device available, saves frame to captured_frame.bmp) +- Video file playback (Windows/macOS only, uses tests/test-data/test.mp4) +- Convert trait (color format conversions) +- PixelFormat enum availability + +Test results are preserved in `build/rust_crate_verification/ccap-test/` including: +- `captured_frame.bmp` - Captured camera frame (if camera available) +- Test binary and source code + +To clean up manually: `rm -rf build/rust_crate_verification` + ## Path Handling Technique All scripts use the following technique to ensure they can be executed from any directory: diff --git a/scripts/publish_rust_crate.sh b/scripts/publish_rust_crate.sh new file mode 100755 index 00000000..4db244aa --- /dev/null +++ b/scripts/publish_rust_crate.sh @@ -0,0 +1,764 @@ +#!/usr/bin/env bash + +# Publish the Rust crate (bindings/rust) to crates.io. +# +# Features: +# - Environment checks (cargo/rustup, rustfmt/clippy) +# - Detect first publish vs update (crates.io existence) +# - Branch-aware version defaulting (non-main => pre-release '-test.*') +# - -y/--yes for non-interactive default choices +# - Build & test pipeline (static-link + build-source) +# - Package list + dry-run publish before real publish + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +RUST_DIR="${PROJECT_ROOT}/bindings/rust" +CARGO_TOML="${RUST_DIR}/Cargo.toml" + +DEFAULT_YES=0 +REQUESTED_VERSION="" +DO_PUBLISH=1 +ALLOW_NON_SELF_CONTAINED=0 +ALLOW_DEFAULT_STATIC_LINK=0 +PUBLISH_MODE="" +AUTO_PREPARE=0 +KEEP_NATIVE=0 +PUBLISH_TEST_FLAG=0 + +usage() { + cat <<'USAGE' +Usage: + scripts/publish_rust_crate.sh [-y|--yes] [--mode ] [--version ] [--dry-run] + [--publish] [--keep-native] + [--allow-non-self-contained] [--allow-default-static-link] + +Options: + -y, --yes Accept defaults (non-interactive). + --publish When combined with -y, enables one-step test version publish + (auto-appends -test.. suffix). + --mode Publish workflow preset. One of: + - dry-run : Run verification only (no publish). Auto-prepares crate for real-world packaging. + - preview : Publish a pre-release (recommended on non-main). Auto-prepares native sources. + - stable : Publish a stable release (recommended on main). Auto-prepares native sources. + - expert : Publish as-is (may be non-self-contained / static-link default). Requires overrides. + --version Target version to publish (e.g. 1.5.0-alpha.1). + If not on main and has no pre-release part, the script + will append a '-test..' suffix by default. + --dry-run Only run 'cargo publish --dry-run' (no actual publish). + + --keep-native Keep bindings/rust/native/ after the run if it was auto-created. + + --allow-non-self-contained + Allow publishing even if bindings/rust/native/ is missing. + (NOT recommended for stable releases; intended for preview/testing only.) + + --allow-default-static-link + Allow publishing even if Cargo.toml default features include + 'static-link' (which is typically not crates.io-friendly). + +Notes: + - When '-y --publish' are used together, automatically publishes a test version + with -test.. suffix without further prompts (preview mode). + - This script publishes whatever package name is in bindings/rust/Cargo.toml. + - crates.io disallows overwriting an existing version; each publish must use a + new unique version. +USAGE +} + +log() { + printf "%s\n" "$*" +} + +warn() { + printf "WARN: %s\n" "$*" >&2 +} + +die() { + printf "ERROR: %s\n" "$*" >&2 + exit 1 +} + +prompt_yn() { + # prompt_yn "Question?" default(Y/N) + local prompt="$1" + local def="$2" + local ans + local hint + + # Display default as uppercase, alternative as lowercase, e.g. "Y/n" or "y/N". + if [[ "${def}" == "Y" ]]; then + hint="Y/n" + else + hint="y/N" + fi + + if [[ "${DEFAULT_YES}" -eq 1 ]]; then + [[ "${def}" == "Y" ]] && return 0 || return 1 + fi + + while true; do + read -r -p "${prompt} [${hint}]: " ans || true + ans="${ans:-$def}" + case "${ans}" in + Y | y) return 0 ;; + N | n) return 1 ;; + *) echo "Please answer Y or N." ;; + esac + done +} + +read_default_features() { + # Print the default feature list as a comma-separated string, or empty. + python3 - "$CARGO_TOML" <<'PY' +import re, sys, pathlib + +path = pathlib.Path(sys.argv[1]) +text = path.read_text(encoding='utf-8') + +m = re.search(r'(?ms)^\[features\]\s*(.*?)(?=^\[|\Z)', text) +if not m: + print("") + raise SystemExit(0) + +feat = m.group(1) +dm = re.search(r'(?m)^\s*default\s*=\s*\[(.*?)\]\s*$', feat) +if not dm: + print("") + raise SystemExit(0) + +items = [] +for raw in dm.group(1).split(','): + s = raw.strip().strip('"').strip("'") + if s: + items.append(s) + +print(','.join(items)) +PY +} + +csv_has_item() { + # csv_has_item "a,b,c" "b" + local csv="$1" + local item="$2" + [[ ",${csv}," == *",${item},"* ]] +} + +set_default_features() { + # set_default_features "build-source" "static-link" + # Replaces [features].default list with provided items. + python3 - "$CARGO_TOML" "$@" <<'PY' +import re, sys, pathlib + +path = pathlib.Path(sys.argv[1]) +items = [x for x in sys.argv[2:] if x] +text = path.read_text(encoding='utf-8') + +m = re.search(r'(?ms)^\[features\]\s*(.*?)(?=^\[|\Z)', text) +if not m: + raise SystemExit("[features] section not found in Cargo.toml") + +feat_body = m.group(1) +new_default = 'default = [' + ', '.join([f'"{x}"' for x in items]) + ']' + +if re.search(r'(?m)^\s*default\s*=\s*\[.*?\]\s*$', feat_body): + feat_body2, n = re.subn(r'(?m)^\s*default\s*=\s*\[.*?\]\s*$', new_default, feat_body, count=1) + if n != 1: + raise SystemExit("Failed to update [features].default") +else: + # Insert at top of [features] section + feat_body2 = new_default + "\n" + feat_body + +new_text = text[:m.start()] + "[features]\n" + feat_body2 + text[m.end():] +path.write_text(new_text, encoding='utf-8') +PY +} + +vendor_native_sources() { + # vendor_native_sources + # Copies /include and /src into bindings/rust/native/{include,src} + local dest="$1" + local src_root="${PROJECT_ROOT}" + + [[ -d "${src_root}/include" ]] || die "Cannot vendor: missing ${src_root}/include" + [[ -d "${src_root}/src" ]] || die "Cannot vendor: missing ${src_root}/src" + + mkdir -p "${dest}" + rm -rf "${dest}/include" "${dest}/src" + mkdir -p "${dest}/include" "${dest}/src" + + # Use tar for robust, fast directory copy without requiring rsync. + (cd "${src_root}" && tar -cf - include src) | (cd "${dest}" && tar -xf -) +} + +select_publish_mode() { + # Returns selected mode string. + if [[ "${DEFAULT_YES}" -eq 1 ]]; then + # Safer default in non-interactive mode. + printf "%s" "dry-run" + return 0 + fi + + echo "Select publish workflow:" >&2 + echo " 1) dry-run - verify only (no publish), auto-prepare crate packaging" >&2 + echo " 2) preview - publish a pre-release, auto-prepare native + friendly defaults" >&2 + echo " 3) stable - publish a stable release, auto-prepare native + friendly defaults" >&2 + echo " 4) expert - publish as-is (requires override flags), not recommended" >&2 + + local choice + choice="$(prompt_text "Enter choice" "1")" + case "${choice}" in + 1 | dry-run) printf "%s" "dry-run" ;; + 2 | preview) printf "%s" "preview" ;; + 3 | stable) printf "%s" "stable" ;; + 4 | expert) printf "%s" "expert" ;; + *) + warn "Unknown choice '${choice}', defaulting to dry-run." + printf "%s" "dry-run" + ;; + esac +} + +prompt_text() { + # prompt_text "Prompt" "default" + local prompt="$1" + local def="$2" + local ans + + if [[ "${DEFAULT_YES}" -eq 1 ]]; then + printf "%s" "${def}" + return 0 + fi + + read -r -p "${prompt} [default: ${def}]: " ans || true + ans="${ans:-$def}" + printf "%s" "${ans}" +} + +require_cmd() { + local cmd="$1" + command -v "${cmd}" >/dev/null 2>&1 +} + +install_rustup() { + require_cmd curl || die "curl not found; please install curl first to auto-install Rust toolchain" + + log "Installing Rust toolchain via rustup..." + curl https://sh.rustup.rs -sSf | sh -s -- -y + # shellcheck disable=SC1090 + source "${HOME}/.cargo/env" +} + +ensure_rust_toolchain() { + if ! require_cmd cargo; then + warn "cargo not found." + if prompt_yn "Install Rust toolchain now (rustup)?" "Y"; then + install_rustup + else + die "cargo is required. Please install Rust (rustup) and re-run this script." + fi + fi + + if require_cmd rustup; then + # Ensure rustfmt/clippy if possible + if prompt_yn "Ensure rustfmt & clippy components are installed?" "Y"; then + rustup component add rustfmt clippy >/dev/null 2>&1 || true + fi + else + warn "rustup not found; skipping automatic rustfmt/clippy installation." + fi +} + +read_package_field() { + # read_package_field + python3 - "$CARGO_TOML" "$1" <<'PY' +import re, sys, pathlib + +path = pathlib.Path(sys.argv[1]) +field = sys.argv[2] +text = path.read_text(encoding='utf-8') + +# Find the [package] section +m = re.search(r'(?ms)^\[package\]\s*(.*?)\n\[', text) +if not m: + # fallback: [package] till EOF + m = re.search(r'(?ms)^\[package\]\s*(.*)\Z', text) +if not m: + print("") + sys.exit(0) + +pkg = m.group(1) +fm = re.search(rf'(?m)^\s*{re.escape(field)}\s*=\s*"([^"]+)"\s*$', pkg) +print(fm.group(1) if fm else "") +PY +} + +set_package_version() { + local new_ver="$1" + python3 - "$CARGO_TOML" "$new_ver" <<'PY' +import re, sys, pathlib + +path = pathlib.Path(sys.argv[1]) +new_ver = sys.argv[2] +text = path.read_text(encoding='utf-8') + +def repl(m): + body = m.group(1) + # Replace only the first version = "..." within [package] + body2, n = re.subn(r'(?m)^(\s*version\s*=\s*")[^"]+("\s*)$', rf"\g<1>{new_ver}\2", body, count=1) + if n != 1: + raise SystemExit("Failed to update [package].version in Cargo.toml") + return "[package]\n" + body2 + +m = re.search(r'(?ms)^\[package\]\s*(.*?)(?=^\[|\Z)', text) +if not m: + raise SystemExit("[package] section not found in Cargo.toml") + +new_text = text[:m.start()] + repl(m) + text[m.end():] +path.write_text(new_text, encoding='utf-8') +PY +} + +cratesio_has_exact_crate() { + local crate="$1" + # cargo search output begins with: = "" ... + local out + out="$(cargo search "^${crate}$" --limit 10 2>/dev/null || true)" + echo "${out}" | grep -E "^${crate} = \"" >/dev/null 2>&1 +} + +cratesio_latest_version() { + local crate="$1" + local out + out="$(cargo search "^${crate}$" --limit 10 2>/dev/null || true)" + # Extract the first exact match version if present + echo "${out}" | sed -n -E "s/^${crate} = \"([^\"]+)\".*$/\1/p" | head -n 1 +} + +git_branch() { + git -C "${PROJECT_ROOT}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "" +} + +git_short_sha() { + git -C "${PROJECT_ROOT}" rev-parse --short HEAD 2>/dev/null || echo "nogit" +} + +timestamp_utc() { + date -u +%Y%m%d%H%M%S +} + +strip_prerelease() { + # strip_prerelease 1.2.3-alpha.1 => 1.2.3 + echo "$1" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/' +} + +bump_patch() { + local base="$1" + local major minor patch + IFS='.' read -r major minor patch <<<"${base}" + [[ -n "${major}" && -n "${minor}" && -n "${patch}" ]] || return 1 + patch=$((patch + 1)) + echo "${major}.${minor}.${patch}" +} + +ensure_ccap_static_lib_for_static_link() { + # Only needed for default (static-link) mode. + local debug_lib="${PROJECT_ROOT}/build/Debug/libccap.a" + local release_lib="${PROJECT_ROOT}/build/Release/libccap.a" + + if [[ -f "${debug_lib}" || -f "${release_lib}" ]]; then + return 0 + fi + + warn "Pre-built libccap.a not found (expected for static-link dev mode)." + if ! prompt_yn "Build C++ library now (CMake Debug) so static-link tests can run?" "Y"; then + warn "Skipping static-link tests due to missing libccap.a." + return 1 + fi + + require_cmd cmake || die "cmake not found; please install cmake or build libccap.a manually" + + log "Configuring & building C++ library (Debug)..." + mkdir -p "${PROJECT_ROOT}/build/Debug" + cmake -S "${PROJECT_ROOT}" -B "${PROJECT_ROOT}/build/Debug" \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCCAP_BUILD_EXAMPLES=OFF \ + -DCCAP_BUILD_TESTS=OFF \ + -DCCAP_BUILD_CLI=OFF >/dev/null + cmake --build "${PROJECT_ROOT}/build/Debug" --config Debug --parallel "$(nproc 2>/dev/null || echo 4)" + + [[ -f "${debug_lib}" ]] || warn "libccap.a still not found after build; static-link tests may fail." + return 0 +} + +ensure_logged_in_hint() { + local cred1="${HOME}/.cargo/credentials.toml" + local cred2="${HOME}/.cargo/credentials" + + if [[ ! -f "${cred1}" && ! -f "${cred2}" ]]; then + warn "Cargo does not appear to be logged in (no credentials file found)." + warn "If publish fails with an authentication error, run: cargo login " + fi +} + +main() { + local native_autocreated=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + -y | --yes) + DEFAULT_YES=1 + shift + ;; + --publish) + PUBLISH_TEST_FLAG=1 + shift + ;; + --mode) + PUBLISH_MODE="${2:-}" + [[ -n "${PUBLISH_MODE}" ]] || die "--mode requires a value" + shift 2 + ;; + --version) + REQUESTED_VERSION="${2:-}" + [[ -n "${REQUESTED_VERSION}" ]] || die "--version requires a value" + shift 2 + ;; + --dry-run) + DO_PUBLISH=0 + shift + ;; + --keep-native) + KEEP_NATIVE=1 + shift + ;; + --allow-non-self-contained) + ALLOW_NON_SELF_CONTAINED=1 + shift + ;; + --allow-default-static-link) + ALLOW_DEFAULT_STATIC_LINK=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1 (use --help)" + ;; + esac + done + + [[ -f "${CARGO_TOML}" ]] || die "Cargo.toml not found at ${CARGO_TOML}" + + ensure_rust_toolchain + + require_cmd git || warn "git not found; branch-aware defaults may be unavailable." + + local branch + branch="$(git_branch)" + local sha + sha="$(git_short_sha)" + local on_main=0 + if [[ "${branch}" == "main" ]]; then + on_main=1 + fi + + local pkg_name + pkg_name="$(read_package_field name)" + [[ -n "${pkg_name}" ]] || die "Failed to read [package].name from Cargo.toml" + + local current_version + current_version="$(read_package_field version)" + [[ -n "${current_version}" ]] || die "Failed to read [package].version from Cargo.toml" + + log "Publishing crate from: ${RUST_DIR}" + log "Package name : ${pkg_name}" + log "Current version : ${current_version}" + + # Handle -y --publish combination: auto-publish test version + if [[ "${DEFAULT_YES}" -eq 1 && "${PUBLISH_TEST_FLAG}" -eq 1 ]]; then + log "Auto-publish mode detected (-y --publish): publishing test version" + PUBLISH_MODE="preview" + DO_PUBLISH=1 + AUTO_PREPARE=1 + + # Force test version if not already specified + if [[ -z "${REQUESTED_VERSION}" ]]; then + local base + base="$(strip_prerelease "${current_version}")" + REQUESTED_VERSION="${base}-test.$(timestamp_utc).${sha}" + log "Auto-generated test version: ${REQUESTED_VERSION}" + elif [[ "${REQUESTED_VERSION}" != *-test.* ]]; then + # Ensure test suffix exists + REQUESTED_VERSION="${REQUESTED_VERSION}-test.$(timestamp_utc).${sha}" + log "Appended test suffix: ${REQUESTED_VERSION}" + fi + fi + + if [[ -z "${PUBLISH_MODE}" ]]; then + PUBLISH_MODE="$(select_publish_mode)" + fi + + case "${PUBLISH_MODE}" in + dry-run) + DO_PUBLISH=0 + AUTO_PREPARE=1 + ;; + preview) + AUTO_PREPARE=1 + ;; + stable) + AUTO_PREPARE=1 + ;; + expert) + # No auto changes; requires explicit overrides. + AUTO_PREPARE=0 + ;; + *) + warn "Unknown --mode '${PUBLISH_MODE}', defaulting to dry-run." + DO_PUBLISH=0 + AUTO_PREPARE=1 + PUBLISH_MODE="dry-run" + ;; + esac + + if [[ "${PUBLISH_MODE}" == "stable" && "${on_main}" -ne 1 ]]; then + warn "Mode 'stable' selected but you are not on main." + if ! prompt_yn "Switch to preview mode automatically?" "Y"; then + die "Refusing stable publish from non-main." + fi + PUBLISH_MODE="preview" + fi + + # Correctness guard: default features should be crates.io-friendly. + # If default includes static-link, typical users will fail without a pre-built libccap. + local default_features + default_features="$(read_default_features)" + if [[ -n "${default_features}" ]] && csv_has_item "${default_features}" "static-link"; then + warn "Cargo.toml default features include 'static-link' (${default_features})." + warn "This is usually NOT crates.io-friendly: users won't have pre-built libccap.a by default." + warn "Recommendation: publish with default = ['build-source'] (and keep 'static-link' for repo dev)." + + if [[ "${ALLOW_DEFAULT_STATIC_LINK}" -ne 1 ]]; then + if [[ "${AUTO_PREPARE}" -eq 1 ]]; then + if prompt_yn "Auto-fix Cargo.toml defaults to ['build-source'] for crates.io?" "Y"; then + set_default_features "build-source" + default_features="$(read_default_features)" + log "Updated Cargo.toml default features: ${default_features}" + else + if [[ "${DO_PUBLISH}" -eq 1 ]]; then + die "Refusing to publish: default features include 'static-link'. Re-run with --allow-default-static-link to override (not recommended)." + else + warn "Continuing because this is a dry-run; publish would be refused unless overridden." + fi + fi + else + if [[ "${DO_PUBLISH}" -eq 1 ]]; then + die "Refusing to publish: default features include 'static-link'. Re-run with --allow-default-static-link to override (not recommended)." + else + warn "Continuing because this is a dry-run; publish would be refused unless overridden." + fi + fi + fi + fi + + if [[ -n "${branch}" ]]; then + log "Git branch : ${branch} (${sha})" + if [[ "${on_main}" -ne 1 ]]; then + warn "You are not on 'main'. It is recommended to publish only a pre-release (test/alpha/beta/rc)." + fi + fi + + # Determine whether this is first publish. + local exists=0 + if cratesio_has_exact_crate "${pkg_name}"; then + exists=1 + local remote_ver + remote_ver="$(cratesio_latest_version "${pkg_name}")" + log "crates.io status : exists (latest: ${remote_ver})" + else + log "crates.io status : not found (first publish)" + fi + + if [[ "${exists}" -eq 0 ]]; then + if ! prompt_yn "Crate '${pkg_name}' not found on crates.io. Publish as a NEW crate?" "Y"; then + die "Aborted by user." + fi + fi + + # Propose a target version. + local base + base="$(strip_prerelease "${current_version}")" + local default_target + if [[ "${on_main}" -eq 1 ]]; then + default_target="$(bump_patch "${base}" || echo "${base}")" + else + default_target="${base}-test.$(timestamp_utc).${sha}" + fi + + local target_version + if [[ -n "${REQUESTED_VERSION}" ]]; then + target_version="${REQUESTED_VERSION}" + else + target_version="$(prompt_text "Enter version to publish" "${default_target}")" + fi + + # If not on main, enforce pre-release by default. + if [[ "${on_main}" -ne 1 ]]; then + if [[ "${target_version}" != *-* ]]; then + warn "Version '${target_version}' has no pre-release part and you are not on main." + if prompt_yn "Append test suffix automatically?" "Y"; then + target_version="${target_version}-test.$(timestamp_utc).${sha}" + else + die "Refusing to publish a stable version from a non-main branch. Use a pre-release (e.g. 1.5.0-test.1)." + fi + fi + fi + + if [[ "${target_version}" == "${current_version}" ]]; then + warn "Target version equals current version. crates.io will reject republishing the same version." + if ! prompt_yn "Continue anyway?" "N"; then + die "Please choose a new version." + fi + fi + + log "Version change : ${current_version} -> ${target_version}" + if ! prompt_yn "Update Cargo.toml to '${target_version}' and proceed?" "Y"; then + die "Aborted by user." + fi + + set_package_version "${target_version}" + + # Update lockfile if present (best-effort). + if [[ -f "${RUST_DIR}/Cargo.lock" ]]; then + (cd "${RUST_DIR}" && cargo update -p "${pkg_name}" >/dev/null 2>&1) || true + fi + + ensure_logged_in_hint + + log "Running Rust checks & tests..." + pushd "${RUST_DIR}" >/dev/null + + if [[ ! -d "${RUST_DIR}/native" ]]; then + warn "bindings/rust/native/ not found." + warn "This makes the published crate NOT self-contained for typical crates.io users." + warn "Recommendation: vendor include/ + src/ into bindings/rust/native/ before publishing." + + if [[ "${AUTO_PREPARE}" -eq 1 ]]; then + if prompt_yn "Auto-vendor native sources into bindings/rust/native now?" "Y"; then + vendor_native_sources "${RUST_DIR}/native" + native_autocreated=1 + log "Vendored native sources into bindings/rust/native/." + else + if [[ "${ALLOW_NON_SELF_CONTAINED}" -ne 1 ]]; then + if [[ "${DO_PUBLISH}" -eq 1 ]]; then + die "Refusing to publish: missing bindings/rust/native/. Re-run with --allow-non-self-contained for preview/testing only." + else + warn "Continuing because this is a dry-run; publish would be refused unless overridden." + fi + fi + fi + else + if [[ "${ALLOW_NON_SELF_CONTAINED}" -ne 1 ]]; then + if [[ "${DO_PUBLISH}" -eq 1 ]]; then + die "Refusing to publish: missing bindings/rust/native/. Re-run with --allow-non-self-contained for preview/testing only." + else + warn "Continuing because this is a dry-run; publish would be refused unless overridden." + fi + fi + fi + fi + + if require_cmd rustfmt; then + cargo fmt --all + else + warn "rustfmt not available; skipping format check." + fi + + if require_cmd cargo-clippy; then + # Clippy on the crate's default feature set. + cargo clippy --all-targets -- -D warnings + else + warn "clippy not available; skipping clippy." + fi + + # Static-link (dev) mode: optional, may need pre-built libccap.a. + # NOTE: When default features are set to build-source for crates.io friendliness, + # running `--features static-link` without `--no-default-features` would enable + # BOTH build-source and static-link, which does NOT actually validate the + # pre-built-library (static-link) workflow. + local did_static=0 + local run_static=0 + if [[ "${PUBLISH_MODE}" == "expert" ]]; then + run_static=1 + else + if prompt_yn "Also run static-link (dev) tests? (requires pre-built libccap.a)" "N"; then + run_static=1 + fi + fi + + if [[ "${run_static}" -eq 1 ]]; then + if ensure_ccap_static_lib_for_static_link; then + did_static=1 + cargo test --verbose --no-default-features --features static-link + else + did_static=0 + fi + fi + + # Build-source (dist) mode: must work for crates.io. + cargo test --verbose --no-default-features --features build-source + + # Build release artifacts as a sanity check. + if [[ "${did_static}" -eq 1 ]]; then + cargo build --release --no-default-features --features static-link + fi + cargo build --release --no-default-features --features build-source + + log "Packaging check (cargo package)..." + cargo package --list >/dev/null + # Validate crates.io build in distribution mode. + cargo publish --dry-run --no-default-features --features build-source + + if [[ "${DO_PUBLISH}" -eq 0 ]]; then + log "Dry-run completed (no publish requested)." + if [[ "${native_autocreated}" -eq 1 && "${KEEP_NATIVE}" -ne 1 ]]; then + rm -rf "${RUST_DIR}/native" + log "Removed auto-created bindings/rust/native/ (use --keep-native to keep it)." + fi + popd >/dev/null + return 0 + fi + + # Skip confirmation prompt for auto-publish mode + if [[ "${PUBLISH_TEST_FLAG}" -ne 1 ]]; then + if ! prompt_yn "Proceed to publish '${pkg_name}' version '${target_version}' to crates.io?" "N"; then + die "Aborted by user after dry-run." + fi + else + log "Auto-publishing test version to crates.io..." + fi + + if cargo publish --no-default-features --features build-source; then + log "✅ Publish succeeded: ${pkg_name} ${target_version}" + log "Next: verify on crates.io and docs.rs." + else + warn "Publish failed. Common causes:" + warn "- Not logged in: run 'cargo login '" + warn "- Version already exists on crates.io (must be unique)" + warn "- Missing system deps for bindgen (libclang) on your machine" + exit 1 + fi + + if [[ "${native_autocreated}" -eq 1 && "${KEEP_NATIVE}" -ne 1 ]]; then + rm -rf "${RUST_DIR}/native" + log "Removed auto-created bindings/rust/native/ (use --keep-native to keep it)." + fi + + popd >/dev/null +} + +main "$@" diff --git a/scripts/test_remote_crate.sh b/scripts/test_remote_crate.sh new file mode 100755 index 00000000..2b6be91a --- /dev/null +++ b/scripts/test_remote_crate.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash + +# Test ccap-rs crate from crates.io +# +# Usage: +# scripts/test_remote_crate.sh [version] +# +# Examples: +# scripts/test_remote_crate.sh # Test latest version +# scripts/test_remote_crate.sh 1.5.0-test.xxx # Test specific version + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +VERSION="${1:-latest}" +TEST_DIR="${PROJECT_ROOT}/build/rust_crate_verification" + +log() { + printf "\033[0;32m[TEST]\033[0m %s\n" "$*" +} + +warn() { + printf "\033[0;33m[WARN]\033[0m %s\n" "$*" >&2 +} + +error() { + printf "\033[0;31m[ERROR]\033[0m %s\n" "$*" >&2 +} + +die() { + error "$*" + exit 1 +} + +cleanup() { + if [[ -d "${TEST_DIR}" ]]; then + log "Cleaning up test directory: ${TEST_DIR}" + rm -rf "${TEST_DIR}" + fi +} + +# Note: cleanup is disabled to preserve test results (captured images, etc.) +# Uncomment the line below to enable automatic cleanup on exit +# trap cleanup EXIT + +main() { + log "Testing ccap-rs from crates.io" + + # If version is "latest", fetch the actual latest version from crates.io + if [[ "${VERSION}" == "latest" ]]; then + log "Fetching latest version from crates.io..." + local latest_version + # Support SemVer prerelease (-...) and build metadata (+...). + latest_version=$(cargo search ccap-rs --limit 1 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?' | head -1 || echo "") + + if [[ -n "${latest_version}" ]]; then + VERSION="${latest_version}" + log "Found latest version: ${VERSION}" + else + die "Failed to fetch latest version from crates.io" + fi + fi + + log "Version: ${VERSION}" + log "Test directory: ${TEST_DIR}" + + # Clean up any existing test directory + rm -rf "${TEST_DIR}" + mkdir -p "${TEST_DIR}" + + # Create test project + log "Creating test project..." + cd "${TEST_DIR}" + cargo init --bin ccap-test --quiet + + cd ccap-test + + # Add dependency to Cargo.toml + log "Adding ccap-rs dependency (version: ${VERSION})..." + python3 - "${VERSION}" <<'PY' +import re +import sys +from pathlib import Path + +version = sys.argv[1] +path = Path("Cargo.toml") +text = path.read_text(encoding="utf-8") + +dep_line = f'ccap-rs = "{version}"\n' + +# Find the [dependencies] section (until the next [section] or EOF) +m = re.search(r'(?ms)^\[dependencies\]\s*\n(.*?)(^\[|\Z)', text) + +if m: + body = m.group(1) + # Replace existing ccap-rs entry if present; otherwise insert at top of the section. + if re.search(r'(?m)^ccap-rs\s*=\s*"[^"]*"\s*$', body): + body2 = re.sub(r'(?m)^ccap-rs\s*=\s*"[^"]*"\s*$', dep_line.rstrip("\n"), body, count=1) + else: + body2 = dep_line + body + text = text[: m.start(1)] + body2 + text[m.end(1) :] +else: + # No [dependencies] section - append one. + if not text.endswith("\n"): + text += "\n" + text += "\n[dependencies]\n" + dep_line + +path.write_text(text, encoding="utf-8") +PY + + # Debug: Show Cargo.toml content + if [[ "${CCAP_TEST_DEBUG:-0}" == "1" ]]; then + log "Debug: Cargo.toml content:" + cat Cargo.toml + fi + + # Get the actual version being used + log "Running cargo update..." + if ! cargo update --quiet; then + warn "cargo update failed (possibly offline or registry issues). Continuing..." + fi + + local actual_version + actual_version=$(cargo metadata --format-version=1 2>/dev/null | + python3 -c "import sys, json; data = json.load(sys.stdin); pkgs = data.get('packages', []); pkg = next((p for p in pkgs if p.get('name') == 'ccap-rs'), None); print(pkg.get('version') if pkg else 'unknown')" 2>/dev/null || echo "unknown") + + log "Testing ccap-rs version: ${actual_version}" + + # Create test code + log "Creating test code..." + + # Copy test video file if it exists (for video playback test) + local test_video="${PROJECT_ROOT}/tests/test-data/test.mp4" + if [[ -f "${test_video}" ]]; then + log "Copying test video file..." + mkdir -p tests/test-data + cp "${test_video}" tests/test-data/ + fi + + cat >src/main.rs <<'RUST_CODE' +use ccap::{Provider, PixelFormat, Convert, Utils}; + + +fn main() { + println!("=== ccap-rs Remote Crate Verification ===\n"); + + // Test 1: Create provider and list available devices + println!("Test 1: Listing camera devices"); + let has_devices = match Provider::new() { + Ok(provider) => { + match provider.find_device_names() { + Ok(devices) => { + if devices.is_empty() { + println!(" ⚠️ No camera devices found (this is OK in CI/headless environments)"); + false + } else { + println!(" ✓ Found {} device(s):", devices.len()); + for (i, device) in devices.iter().enumerate() { + println!(" {}. {}", i + 1, device); + } + true + } + } + Err(e) => { + eprintln!(" ✗ Error listing devices: {}", e); + std::process::exit(1); + } + } + } + Err(e) => { + eprintln!(" ✗ Error creating provider: {}", e); + std::process::exit(1); + } + }; + println!(); + + // Test 1.5: Capture image from camera if available + if has_devices { + println!("Test 1.5: Capturing image from camera"); + match Provider::new() { + Ok(mut provider) => { + // Open first device by name + match provider.find_device_names() { + Ok(devices) if !devices.is_empty() => { + // Open device with the device name + match provider.open_device(Some(&devices[0]), false) { + Ok(_) => { + // Start capture + if let Err(e) = provider.start() { + eprintln!(" ⚠️ Failed to start camera: {}", e); + } else { + // Wait a bit for camera to initialize + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Try to grab a frame + match provider.grab_frame(2000) { + Ok(Some(frame)) => { + let width = frame.width(); + let height = frame.height(); + let format = frame.pixel_format(); + + println!(" ✓ Captured frame: {}x{} {:?}", width, height, format); + + // Save frame as BMP using ccap's built-in function + let filename = "captured_frame"; + match Utils::save_frame_as_bmp(&frame, filename) { + Ok(_) => { + println!(" ✓ Saved captured frame to {}.bmp", filename); + } + Err(e) => { + eprintln!(" ⚠️ Failed to save frame: {}", e); + } + } + } + Ok(None) => { + eprintln!(" ⚠️ Failed to grab frame: timeout"); + } + Err(e) => { + eprintln!(" ⚠️ Failed to grab frame: {}", e); + } + } + + // Stop camera + let _ = provider.stop(); + } + } + Err(e) => { + eprintln!(" ⚠️ Failed to open camera: {}", e); + } + } + } + _ => {} + } + } + Err(e) => { + eprintln!(" ⚠️ Failed to create provider: {}", e); + } + } + println!(); + } + + // Test 2: Test pixel format conversions with Convert trait + println!("Test 2: Testing color conversion functions"); + + // Create test data (16x16 NV12 image) + let width = 16; + let height = 16; + let y_size = width * height; + let uv_size = width * height / 2; + + let y_plane = vec![128u8; y_size]; // Gray Y plane + let uv_plane = vec![128u8; uv_size]; // Neutral UV plane + + // For simple test without padding: stride equals width + let y_stride = width; + let uv_stride = width; + + // Try NV12 to RGB24 conversion + match Convert::nv12_to_rgb24(&y_plane, y_stride, &uv_plane, uv_stride, width as u32, height as u32) { + Ok(rgb_data) => { + let expected_size = width * height * 3; + if rgb_data.len() == expected_size { + println!(" ✓ NV12 to RGB24 conversion successful ({} bytes)", rgb_data.len()); + } else { + eprintln!(" ✗ Unexpected output size: {} (expected {})", rgb_data.len(), expected_size); + std::process::exit(1); + } + } + Err(e) => { + eprintln!(" ✗ Conversion error: {}", e); + std::process::exit(1); + } + } + + // Try NV12 to BGR24 conversion + match Convert::nv12_to_bgr24(&y_plane, y_stride, &uv_plane, uv_stride, width as u32, height as u32) { + Ok(bgr_data) => { + println!(" ✓ NV12 to BGR24 conversion successful ({} bytes)", bgr_data.len()); + } + Err(e) => { + eprintln!(" ✗ Conversion error: {}", e); + std::process::exit(1); + } + } + println!(); + + // Test 3: Test PixelFormat enum + println!("Test 3: Testing PixelFormat enum"); + let formats = vec![ + PixelFormat::Nv12, + PixelFormat::I420, + PixelFormat::Rgb24, + PixelFormat::Bgr24, + PixelFormat::Rgba32, + PixelFormat::Bgra32, + ]; + + for format in formats { + println!(" ✓ PixelFormat::{:?} available", format); + } + println!(); + + // Test 4: Video file playback (Windows/macOS only) + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + println!("Test 4: Testing video file playback"); + + // Test video file path + let video_path = "tests/test-data/test.mp4"; + + if std::path::Path::new(video_path).exists() { + match Provider::new() { + Ok(mut provider) => { + // Open video file using open_device + match provider.open_device(Some(video_path), false) { + Ok(_) => { + if let Err(e) = provider.start() { + eprintln!(" ⚠️ Failed to start video: {}", e); + } else { + println!(" ✓ Video opened successfully: {}", video_path); + + // Read a few frames + let mut frame_count = 0; + for i in 0..5 { + match provider.grab_frame(1000) { + Ok(Some(frame)) => { + frame_count += 1; + if i == 0 { + println!(" ✓ Read frame {}: {}x{} {:?}", + frame_count, frame.width(), frame.height(), frame.pixel_format()); + } + } + Ok(None) => break, + Err(_) => break, + } + } + + if frame_count > 0 { + println!(" ✓ Successfully read {} frame(s) from video", frame_count); + } else { + eprintln!(" ⚠️ No frames could be read from video"); + } + + let _ = provider.stop(); + } + } + Err(e) => { + eprintln!(" ⚠️ Failed to open video file: {}", e); + } + } + } + Err(e) => { + eprintln!(" ⚠️ Failed to create provider: {}", e); + } + } + } else { + println!(" ⚠️ Test video file not found at {}, skipping video playback test", video_path); + } + println!(); + } + + println!("=== All Tests Passed ✓ ==="); +} +RUST_CODE + + # Build the test + log "Building test project..." + local build_output + if ! build_output=$(cargo build --quiet 2>&1); then + error "Build failed" + echo "$build_output" >&2 + return 1 + fi + + log "Build successful" + + # Run the test + log "Running tests..." + echo "" + if cargo run --quiet; then + echo "" + log "✓ All tests passed successfully" + log "" + log "Test artifacts saved in: ${TEST_DIR}/ccap-test/" + log " - Captured image: captured_frame.bmp (if camera was available)" + log " - Test binary: target/debug/ccap-test" + log "" + log "To clean up test directory manually, run:" + log " rm -rf ${TEST_DIR}" + return 0 + else + echo "" + error "✗ Tests failed" + return 1 + fi +} + +main "$@" diff --git a/scripts/update_version.sh b/scripts/update_version.sh index 39c03a5c..983ba610 100755 --- a/scripts/update_version.sh +++ b/scripts/update_version.sh @@ -91,4 +91,38 @@ update_file "s/version = \".*\"/version = \"$NEW_VERSION\"/" "$PROJECT_ROOT/cona # 4. Update BUILD_AND_INSTALL.md (documentation) update_file "s/Current version: .*/Current version: $NEW_VERSION/" "$PROJECT_ROOT/BUILD_AND_INSTALL.md" "BUILD_AND_INSTALL.md" +# 5. Update Rust crate version (Cargo.toml) +update_file "s/^version = \".*\"$/version = \"$NEW_VERSION\"/" "$PROJECT_ROOT/bindings/rust/Cargo.toml" "Rust crate version (Cargo.toml)" + +# 6. Update Rust lockfile entry for Rust package (Cargo.lock) +RUST_LOCKFILE="$PROJECT_ROOT/bindings/rust/Cargo.lock" +if [ -f "$RUST_LOCKFILE" ]; then + python3 - "$RUST_LOCKFILE" "$NEW_VERSION" <<'PY' +import pathlib, re, sys + +path = pathlib.Path(sys.argv[1]) +new_ver = sys.argv[2] +text = path.read_text() +patterns = [ + r'(?m)(^name = "ccap-rs"\nversion = ")[^"]+("\n)', + r'(?m)(^name = "ccap"\nversion = ")[^"]+("\n)', +] + +new_text = text +count = 0 +for pattern in patterns: + new_text, n = re.subn(pattern, rf"\1{new_ver}\2", new_text, count=1) + if n: + count += n + break +if count: + path.write_text(new_text) + print(f"✅ Updated Rust lockfile package version to {new_ver}") +else: + print(f"⚠️ Rust package entry not found in {path}, lockfile not modified") +PY +else + echo "⚠️ $RUST_LOCKFILE not found, skipping Rust lockfile update" +fi + echo "Version update complete!" diff --git a/src/ccap_convert_frame.cpp b/src/ccap_convert_frame.cpp index 42c6f36c..c9d9f789 100644 --- a/src/ccap_convert_frame.cpp +++ b/src/ccap_convert_frame.cpp @@ -242,7 +242,7 @@ bool inplaceConvertFrame(VideoFrame* frame, PixelFormat toFormat, bool verticalF if (ret) { assert(frame->pixelFormat == toFormat); assert(frame->allocator != nullptr && frame->data[0] == frame->allocator->data()); - frame->sizeInBytes = frame->allocator->size(); + frame->sizeInBytes = static_cast(frame->allocator->size()); } return ret; } diff --git a/src/ccap_imp_windows.cpp b/src/ccap_imp_windows.cpp index 0c4a7042..bc7605ee 100644 --- a/src/ccap_imp_windows.cpp +++ b/src/ccap_imp_windows.cpp @@ -986,7 +986,7 @@ HRESULT STDMETHODCALLTYPE ProviderDirectShow::BufferCB(double SampleTime, BYTE* return S_OK; } -HRESULT STDMETHODCALLTYPE ProviderDirectShow::QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR* __RPC_FAR* ppvObject) { +HRESULT STDMETHODCALLTYPE ProviderDirectShow::QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR * __RPC_FAR * ppvObject) { static constexpr const IID IID_ISampleGrabberCB = { 0x0579154A, 0x2B53, 0x4994, { 0xB0, 0xD0, 0xE7, 0x73, 0x14, 0x8E, 0xFF, 0x85 } }; if (riid == IID_IUnknown) { diff --git a/src/ccap_imp_windows.h b/src/ccap_imp_windows.h index 6e4dd02f..fe45ab6d 100644 --- a/src/ccap_imp_windows.h +++ b/src/ccap_imp_windows.h @@ -93,7 +93,7 @@ class ProviderDirectShow : public ProviderImp, public ISampleGrabberCB { inline FrameOrientation frameOrientation() const { return m_frameOrientation; } private: - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR* __RPC_FAR* ppvObject) override; + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR * __RPC_FAR * ppvObject) override; ULONG STDMETHODCALLTYPE AddRef(void) override; ULONG STDMETHODCALLTYPE Release(void) override;