summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-02-08 12:44:10 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-08 12:44:10 +0100
commit0c20fb86633104744dbccf30ad732296694fff1b (patch)
tree02ffb8494086960b4a84decf3bdc2c8c61bfc4f6
Initial pipewiremain
-rw-r--r--.cursorrules166
-rw-r--r--.gitignore8
-rw-r--r--Cargo.lock2213
-rw-r--r--Cargo.toml56
-rw-r--r--README.md340
-rw-r--r--benches/jpeg_parsing.rs57
-rw-r--r--src/error.rs247
-rw-r--r--src/lib.rs217
-rw-r--r--src/main.rs207
-rw-r--r--src/protocol/frame.rs259
-rw-r--r--src/protocol/jpeg.rs351
-rw-r--r--src/protocol/mod.rs409
-rw-r--r--src/protocol/parser.rs418
-rw-r--r--src/usb/device.rs287
-rw-r--r--src/usb/mod.rs164
-rw-r--r--src/usb/transfer.rs287
-rw-r--r--src/utils/mod.rs350
-rw-r--r--src/video/mod.rs330
-rw-r--r--src/video/pipewire.rs773
-rw-r--r--src/video/stdout.rs277
-rw-r--r--src/video/v4l2.rs321
-rw-r--r--tests/integration_test.rs227
22 files changed, 7964 insertions, 0 deletions
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..12b6201
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,166 @@
+# Rust Rules
+
+## 🧱 Project Architecture
+
+### **Modular Design**
+
+* Organize code into **crates** and **modules**:
+
+ * Use a **workspace** for multi-crate projects (`Cargo.toml` + `Cargo.lock` at root).
+ * Split concerns into crates: e.g., `core`, `network`, `storage`, `cli`, `web`, `domain`, `infra`.
+* Avoid monoliths β€” design for composability.
+
+### **Layered Architecture**
+
+Separate by **responsibility**, not technology:
+
+* **Domain layer** – business logic, domain models, pure logic, no dependencies.
+* **Application layer** – use-cases, orchestrators, service interfaces.
+* **Infrastructure layer** – database, HTTP clients, FS, external APIs.
+* **Presentation layer** – CLI, gRPC, REST API, etc.
+
+Use traits to **abstract interfaces** between layers.
+
+---
+
+## πŸ“¦ Crate and Module Hygiene
+
+### **Use Visibility Thoughtfully**
+
+* Keep as much private (`pub(crate)` or private) as possible.
+* Use `mod.rs` sparingly β€” prefer flat `mod_x.rs` and `mod x;` where possible.
+* Keep `lib.rs` or `main.rs` minimal β€” just wiring and top-level declarations.
+
+### **Predeclare your modules**
+
+Explicitly declare modules in parent files, avoiding implicit module discovery:
+
+```rust
+mod domain;
+mod services;
+```
+
+---
+
+## 🧠 Code Design and Idioms
+
+### **Prefer Composition Over Inheritance**
+
+* Favor structs + traits over enums for extensibility.
+* Use `impl Trait` for abstraction and `dyn Trait` for dynamic dispatch when needed.
+
+### **Minimize Unnecessary Abstractions**
+
+* Don't abstract over one implementation β€” wait for the second one.
+* Don’t use traits where a simple function will do.
+
+### **Idiomatic Error Handling**
+
+* Use `Result<T, E>`, `?`, and `thiserror` or `anyhow` (depending on layer).
+* Business logic: custom error enums (`thiserror`).
+* App layer or CLI: use `anyhow::Result` for bubble-up and crash-on-error.
+
+### **Zero-cost abstractions**
+
+* Use generics, lifetimes, borrowing, and ownership where appropriate.
+* Minimize heap allocations, unnecessary `.clone()`s.
+
+## πŸ› οΈ Tooling and Dev Experience
+
+### **Use Clippy, Rustfmt, and IDEs**
+
+* `clippy`: catch non-idiomatic code.
+* `rustfmt`: consistent formatting.
+* `cargo-expand`: inspect macro-generated code.
+
+### **Use `cargo features` for Flexibility**
+
+* Feature-gate optional deps and functionalities:
+
+```toml
+[features]
+default = ["serde"]
+cli = ["clap"]
+```
+
+## πŸ§ͺ Testing & Quality
+
+### **Test by Layer**
+
+* Unit tests for pure logic.
+* Integration tests (`tests/`) for subsystems and public interfaces.
+* End-to-end/system tests where applicable.
+
+Use `mockall` or `double` for mocking when interface testing is needed.
+
+### **Property-based test* Study open-source Rust projects like `ripgrep`, `tokio`, `tower`, `axum`, or `zellij`.
+
+* Use `proptest` for verifying correctness over ranges of inputs.
+
+## πŸ“ˆ Performance and Safety
+
+### **Measure Before Optimizing**
+
+* Use `cargo bench`, `criterion`, `perf`, or `flamegraph` for real profiling.
+* Don't optimize until there's a clear need.
+
+### **Minimize Unsafe Code**
+
+* Keep `unsafe` blocks minimal, justified, and well-documented.
+* Use crates like `bytemuck`, `zeroize`, or `unsafe-libyaml` only when needed.
+
+## πŸ“š Dependency Hygiene
+
+### **Minimal and Audited Dependencies**
+
+* Prefer well-maintained, minimal, audited crates.
+* Avoid depending on "kitchen sink" crates unless unavoidable.
+* Regularly check for security updates via `cargo audit`.
+
+## 🧾 Documentation & Maintainability
+
+### **Document Public APIs and Crates**
+
+* Use `//!` and `///` comments.
+* Auto-generate docs with `cargo doc --open`.
+
+### **Conventional Naming**
+
+* Stick to Rust conventions: `snake_case` for functions and variables, `PascalCase` for types.
+
+
+### **Limit Shared Mutable State**
+
+* Avoid global mutable state unless it’s guarded and justified.
+
+### **Reproducible Builds**
+
+* Pin versions where critical.
+* Use lockfiles even in libraries (`[package] publish = false` in internal crates).
+
+## πŸ“ Example Folder Structure
+
+```text
+my-app/
+β”œβ”€β”€ Cargo.toml
+β”œβ”€β”€ Cargo.lock
+β”œβ”€β”€ crates/
+β”‚ β”œβ”€β”€ core/
+β”‚ β”œβ”€β”€ api/
+β”‚ β”œβ”€β”€ domain/
+β”‚ └── storage/
+β”œβ”€β”€ bin/
+β”‚ └── cli.rs
+β”œβ”€β”€ tests/
+β”‚ └── integration.rs
+β”œβ”€β”€ docs/
+β”‚ └── architecture.md
+```
+
+## 🧭 Final Advice
+
+* Think in **lifetimes**, **ownership**, and **borrowing**.
+* Design for **testability** and **composability**.
+* Don't fight the compiler β€” **embrace it as your co-architect**.
+* Be conservative with third-party crates β€” **audit and isolate** them if needed.
+* This project is pure Unix - do not make any Windows adjustments.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c3a3c7e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/out
+/out.d
+/pics/
+/target
+/docs
+/examples
+/pipewire
+.cursor/
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..5664e21
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2213 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "aligned-vec"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
+dependencies = [
+ "equator",
+]
+
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
+[[package]]
+name = "annotate-snippets"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e"
+dependencies = [
+ "unicode-width",
+ "yansi-term",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+
+[[package]]
+name = "arg_enum_proc_macro"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "av1-grain"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8"
+dependencies = [
+ "anyhow",
+ "arrayvec",
+ "log",
+ "nom",
+ "num-rational",
+ "v_frame",
+]
+
+[[package]]
+name = "avif-serialize"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.69.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
+dependencies = [
+ "annotate-snippets",
+ "bitflags 2.9.2",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.12.1",
+ "lazy_static",
+ "lazycell",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+]
+
+[[package]]
+name = "bit_field"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29"
+
+[[package]]
+name = "bitstream-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
+
+[[package]]
+name = "built"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
+
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+[[package]]
+name = "bytemuck"
+version = "1.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
+[[package]]
+name = "cc"
+version = "1.2.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f"
+dependencies = [
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
+[[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 = "clap"
+version = "4.5.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cookie-factory"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
+dependencies = [
+ "futures",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "criterion"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928"
+dependencies = [
+ "anes",
+ "cast",
+ "ciborium",
+ "clap",
+ "criterion-plot",
+ "itertools 0.13.0",
+ "num-traits",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338"
+dependencies = [
+ "cast",
+ "itertools 0.13.0",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "ctrlc"
+version = "3.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73"
+dependencies = [
+ "nix 0.30.1",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "downcast"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "equator"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
+dependencies = [
+ "equator-macro",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "exr"
+version = "1.73.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0"
+dependencies = [
+ "bit_field",
+ "half",
+ "lebe",
+ "miniz_oxide",
+ "rayon-core",
+ "smallvec",
+ "zune-inflate",
+]
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fragile"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
+
+[[package]]
+name = "futures"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "geek-szitman-supercamera"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "criterion",
+ "ctrlc",
+ "image",
+ "jpeg-decoder",
+ "mockall",
+ "nix 0.30.1",
+ "pipewire",
+ "pipewire-sys",
+ "rusb",
+ "serde",
+ "serde_json",
+ "signal-hook",
+ "thiserror 2.0.15",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
+]
+
+[[package]]
+name = "gif"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "half"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "image"
+version = "0.25.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "color_quant",
+ "exr",
+ "gif",
+ "image-webp",
+ "num-traits",
+ "png",
+ "qoi",
+ "ravif",
+ "rayon",
+ "rgb",
+ "tiff",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
+]
+
+[[package]]
+name = "imgref"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
+
+[[package]]
+name = "indexmap"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "interpolate_name"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
+name = "jobserver"
+version = "0.1.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
+dependencies = [
+ "getrandom 0.3.3",
+ "libc",
+]
+
+[[package]]
+name = "jpeg-decoder"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
+dependencies = [
+ "rayon",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[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 = "lebe"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
+
+[[package]]
+name = "libc"
+version = "0.2.175"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
+
+[[package]]
+name = "libfuzzer-sys"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
+dependencies = [
+ "arbitrary",
+ "cc",
+]
+
+[[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 = "libspa"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810"
+dependencies = [
+ "bitflags 2.9.2",
+ "cc",
+ "convert_case",
+ "cookie-factory",
+ "libc",
+ "libspa-sys",
+ "nix 0.27.1",
+ "nom",
+ "system-deps",
+]
+
+[[package]]
+name = "libspa-sys"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf0d9716420364790e85cbb9d3ac2c950bde16a7dd36f3209b7dfdfc4a24d01f"
+dependencies = [
+ "bindgen",
+ "cc",
+ "system-deps",
+]
+
+[[package]]
+name = "libusb1-sys"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "loop9"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
+dependencies = [
+ "imgref",
+]
+
+[[package]]
+name = "matchers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
+dependencies = [
+ "regex-automata 0.1.10",
+]
+
+[[package]]
+name = "maybe-rayon"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
+dependencies = [
+ "cfg-if",
+ "rayon",
+]
+
+[[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 = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mockall"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
+dependencies = [
+ "cfg-if",
+ "downcast",
+ "fragile",
+ "mockall_derive",
+ "predicates",
+ "predicates-tree",
+]
+
+[[package]]
+name = "mockall_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
+dependencies = [
+ "cfg-if",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nix"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
+dependencies = [
+ "bitflags 2.9.2",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.9.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[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 = "noop_proc_macro"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+dependencies = [
+ "overload",
+ "winapi",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
+[[package]]
+name = "overload"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pipewire"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda"
+dependencies = [
+ "anyhow",
+ "bitflags 2.9.2",
+ "libc",
+ "libspa",
+ "libspa-sys",
+ "nix 0.27.1",
+ "once_cell",
+ "pipewire-sys",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "pipewire-sys"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "849e188f90b1dda88fe2bfe1ad31fe5f158af2c98f80fb5d13726c44f3f01112"
+dependencies = [
+ "bindgen",
+ "libspa-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
+dependencies = [
+ "plotters-backend",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "predicates"
+version = "3.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
+dependencies = [
+ "anstyle",
+ "predicates-core",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
+[[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 = "profiling"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+dependencies = [
+ "profiling-procmacros",
+]
+
+[[package]]
+name = "profiling-procmacros"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "qoi"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.16",
+]
+
+[[package]]
+name = "rav1e"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
+dependencies = [
+ "arbitrary",
+ "arg_enum_proc_macro",
+ "arrayvec",
+ "av1-grain",
+ "bitstream-io",
+ "built",
+ "cfg-if",
+ "interpolate_name",
+ "itertools 0.12.1",
+ "libc",
+ "libfuzzer-sys",
+ "log",
+ "maybe-rayon",
+ "new_debug_unreachable",
+ "noop_proc_macro",
+ "num-derive",
+ "num-traits",
+ "once_cell",
+ "paste",
+ "profiling",
+ "rand",
+ "rand_chacha",
+ "simd_helpers",
+ "system-deps",
+ "thiserror 1.0.69",
+ "v_frame",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ravif"
+version = "0.11.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b"
+dependencies = [
+ "avif-serialize",
+ "imgref",
+ "loop9",
+ "quick-error",
+ "rav1e",
+ "rayon",
+ "rgb",
+]
+
+[[package]]
+name = "rayon"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "regex"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata 0.4.9",
+ "regex-syntax 0.8.5",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
+dependencies = [
+ "regex-syntax 0.6.29",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax 0.8.5",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.6.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
+
+[[package]]
+name = "rgb"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
+
+[[package]]
+name = "rusb"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4"
+dependencies = [
+ "libc",
+ "libusb1-sys",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.142"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+
+[[package]]
+name = "simd_helpers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
+dependencies = [
+ "quote",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[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 = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck",
+ "pkg-config",
+ "toml",
+ "version-compare",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850"
+dependencies = [
+ "thiserror-impl 2.0.15",
+]
+
+[[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 = "thiserror-impl"
+version = "2.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tiff"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
+dependencies = [
+ "flate2",
+ "jpeg-decoder",
+ "weezl",
+]
+
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "v_frame"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
+dependencies = [
+ "aligned-vec",
+ "num-traits",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version-compare"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[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"
+
+[[package]]
+name = "winnow"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags 2.9.2",
+]
+
+[[package]]
+name = "yansi-term"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5c30ade05e61656247b2e334a031dfd0cc466fadef865bdcdea8d537951bf1"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
+[[package]]
+name = "zune-inflate"
+version = "0.2.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "zune-jpeg"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089"
+dependencies = [
+ "zune-core",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2641f11
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,56 @@
+[package]
+name = "geek-szitman-supercamera"
+version = "0.1.0"
+edition = "2021"
+authors = ["hbens", "Contributors"]
+description = "Rust implementation of Geek szitman supercamera endoscope viewer"
+license = "CC0-1.0"
+repository = "https://github.com/hbens/geek-szitman-supercamera"
+keywords = ["camera", "endoscope", "usb", "pipewire", "v4l2"]
+categories = ["multimedia", "hardware-support"]
+
+[dependencies]
+# Core dependencies
+anyhow = "1.0"
+thiserror = "2.0"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+# USB communication
+rusb = "0.9"
+
+# Video/audio handling
+pipewire = "0.8"
+pipewire-sys = "0.8"
+
+# Image processing
+image = "0.25"
+jpeg-decoder = "0.3"
+
+# System integration
+ctrlc = "3.4"
+signal-hook = "0.3"
+nix = "0.30"
+
+# Serialization and utilities
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+clap = { version = "4.0", features = ["derive"] }
+
+[dev-dependencies]
+mockall = "0.13"
+criterion = "0.7"
+
+[[bench]]
+name = "jpeg_parsing"
+harness = false
+
+[profile.release]
+opt-level = 3
+lto = true
+codegen-units = 1
+panic = "abort"
+
+[profile.dev]
+opt-level = 0
+debug = true
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2a382e8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,340 @@
+# Geek Szitman SuperCamera
+
+Rust implementation of Geek szitman supercamera endoscope viewer with PipeWire support and preparation for V4L2 fallback.
+
+## Features
+
+- USB communication with the endoscope device
+- **PipeWire virtual camera** - Create a discoverable virtual camera node
+- **Stdout video output** - Pipe raw video frames to stdout for external processing
+- UPP protocol implementation
+- JPEG frame processing
+- Modular architecture for maintainability
+
+## PipeWire Virtual Camera
+
+This project includes a **discoverable virtual camera** implementation using PipeWire that creates a `Video/Source` node in the PipeWire graph. The virtual camera can be used by any application that supports PipeWire video sources.
+
+### How it works
+
+The PipeWire backend:
+1. Connects to the PipeWire daemon
+2. Creates a `Stream` configured as a `Video/Source`
+3. Registers the stream with proper metadata (node name, description, media class)
+4. Becomes discoverable by other applications
+
+### Usage
+
+#### 1. Start the virtual camera
+
+```bash
+# Run the application (it will automatically create the PipeWire virtual camera)
+cargo run --bin geek-szitman-supercamera
+
+# Or specify PipeWire backend explicitly
+cargo run --bin geek-szitman-supercamera --backend pipewire
+```
+
+#### 2. Verify the virtual camera appears
+
+```bash
+# List all PipeWire nodes
+pw-dump | jq '.[] | select(.info.props["media.class"]=="Video/Source")'
+
+# Or use pw-top to see the graph
+pw-top
+```
+
+#### 3. Use the virtual camera in applications
+
+The virtual camera will appear as "geek-szitman-supercamera" in applications like:
+- OBS Studio
+- Cheese
+- Google Meet
+- Zoom
+- Any application that supports PipeWire video sources
+
+#### 4. Test with GStreamer
+
+```bash
+# Get the node ID from pw-dump
+NODE_ID=$(pw-dump | jq -r '.[] | select(.info.props["media.class"]=="Video/Source") | .id')
+
+# Test the virtual camera
+gst-launch-1.0 pipewiresrc target-object=$NODE_ID ! videoconvert ! autovideosink
+```
+
+### Configuration
+
+The virtual camera can be configured with:
+- **Node name**: `geek-szitman-supercamera`
+- **Description**: `Geek Szitman SuperCamera - High-quality virtual camera for streaming and recording`
+- **Media class**: `Video/Source`
+- **Format**: RGB24 (for maximum compatibility)
+- **Resolution**: 640x480 (configurable)
+- **Framerate**: 30 FPS (configurable)
+
+### Architecture
+
+The PipeWire implementation follows the official Rust bindings (`pipewire = "0.8"`):
+
+1. **MainLoop β†’ Context β†’ Core**: Standard PipeWire connection pattern
+2. **Stream creation**: Creates a `Video/Source` stream with proper metadata
+3. **Event handling**: Responds to state changes and parameter requests
+4. **Thread safety**: Runs in a separate thread to avoid blocking the main application
+
+### Troubleshooting
+
+#### Virtual camera not appearing
+
+1. Check if PipeWire is running:
+ ```bash
+ systemctl --user status pipewire
+ ```
+
+2. Verify the application started successfully:
+ ```bash
+ journalctl --user -f -u pipewire
+ ```
+
+3. Check for errors in the application logs
+
+#### Permission issues
+
+Ensure your user has access to PipeWire:
+```bash
+# Add user to video group if needed
+sudo usermod -a -G video $USER
+```
+
+#### Format compatibility
+
+The virtual camera currently provides RGB24 format. If you need other formats (YUV420, MJPEG), the backend can be extended to support format negotiation.
+
+## Stdout Video Output
+
+The stdout backend outputs raw video frames to stdout, allowing you to pipe the video stream to other tools like PipeWire, FFmpeg, or custom video processing pipelines.
+
+### How it works
+
+The stdout backend:
+1. Outputs raw video frames directly to stdout
+2. Optionally includes frame metadata headers
+3. Supports multiple header formats (Simple, JSON, Binary)
+4. Can be piped to other tools for further processing
+
+### Usage
+
+#### 1. Basic stdout output
+
+```bash
+# Output raw video frames to stdout
+cargo run --bin geek-szitman-supercamera --backend stdout
+
+# This will output raw JPEG frames to stdout
+```
+
+#### 2. Pipe to PipeWire
+
+```bash
+# Pipe video output to PipeWire using ffmpeg
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+ffmpeg -f mjpeg -i pipe:0 -f v4l2 -pix_fmt yuv420p /dev/video0
+
+# Or use gstreamer
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+gst-launch-1.0 fdsrc fd=0 ! jpegdec ! videoconvert ! v4l2sink device=/dev/video0
+```
+
+```bash
+# WORKING NEWEST COMMAND
+RUST_LOG=off \
+cargo run --release -- --backend stdout 2>/tmp/supercamera.log | \
+gst-launch-1.0 -v fdsrc do-timestamp=true ! \
+ image/jpeg,framerate=30/1,width=640,height=480 ! \
+ jpegparse ! jpegdec ! videoconvert ! videoscale method=lanczos ! \
+ video/x-raw,width=1024,height=768 ! \
+ queue max-size-buffers=1 leaky=downstream ! \
+ fpsdisplaysink video-sink=waylandsink sync=false text-overlay=true
+```
+
+#### 3. Pipe to FFmpeg for recording
+
+```bash
+# Record video to file
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+ffmpeg -f mjpeg -i pipe:0 -c:v libx264 -preset ultrafast -crf 23 output.mp4
+
+# Stream to RTMP
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+ffmpeg -f mjpeg -i pipe:0 -c:v libx264 -preset ultrafast -crf 23 \
+-f flv rtmp://localhost/live/stream
+```
+
+#### 4. Custom video processing
+
+```bash
+# Process with custom Python script
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+python3 process_video.py
+
+# Process with custom C++ application
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+./video_processor
+```
+
+### Configuration Options
+
+The stdout backend supports several configuration options:
+
+#### Header formats
+
+- **Simple**: `FRAME:size:timestamp\n` (human-readable)
+- **JSON**: `{"frame": {"size": size, "timestamp": timestamp}}\n` (structured)
+- **Binary**: 4-byte size + 8-byte timestamp (efficient)
+
+#### Frame metadata
+
+```bash
+# Enable headers with simple format
+cargo run --bin geek-szitman-supercamera --backend stdout --config '{"include_headers": true, "header_format": "simple"}'
+
+# Enable JSON headers for parsing
+cargo run --bin geek-szitman-supercamera --backend stdout --config '{"include_headers": true, "header_format": "json"}'
+```
+
+### Integration Examples
+
+#### With v4l2loopback
+
+```bash
+# Load v4l2loopback module
+sudo modprobe v4l2loopback
+
+# Create virtual video device
+sudo modprobe v4l2loopback video_nr=10 card_label="SuperCamera" exclusive_caps=1
+
+# Pipe video to virtual device
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+ffmpeg -f mjpeg -i pipe:0 -f v4l2 -pix_fmt yuv420p /dev/video10
+```
+
+#### With OBS Studio
+
+```bash
+# Create v4l2loopback device
+sudo modprobe v4l2loopback video_nr=20 card_label="SuperCamera"
+
+# Pipe video to device
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+ffmpeg -f mjpeg -i pipe:0 -f v4l2 -pix_fmt yuv420p /dev/video20
+
+# Add "Video Capture Device" source in OBS, select /dev/video20
+```
+
+#### With custom video processing
+
+```bash
+# Example: Extract frames for analysis
+cargo run --bin geek-szitman-supercamera --backend stdout | \
+ffmpeg -f mjpeg -i pipe:0 -vf "select=eq(pict_type\,I)" -vsync vfr frame_%04d.jpg
+```
+
+### Advantages
+
+- **Flexibility**: Pipe to any tool that accepts video input
+- **Efficiency**: No intermediate video backend overhead
+- **Compatibility**: Works with standard Unix tools and pipelines
+- **Debugging**: Easy to inspect raw video data
+- **Integration**: Simple to integrate with existing video workflows
+
+### Use Cases
+
+- **Video recording**: Pipe to FFmpeg for file output
+- **Streaming**: Pipe to streaming services via FFmpeg
+- **Video processing**: Pipe to custom video analysis tools
+- **Debugging**: Inspect raw video data for troubleshooting
+- **Integration**: Connect to existing video pipelines
+
+## Building
+
+```bash
+# Build the project
+cargo build
+
+# Build with optimizations
+cargo build --release
+
+# Run tests
+cargo test
+
+# Check for issues
+cargo clippy
+```
+
+## Dependencies
+
+- **Rust**: 1.70+
+- **PipeWire**: 0.3+ (with development headers)
+- **System libraries**: `libpipewire-0.3-dev`
+
+### Installing dependencies (Arch Linux)
+
+```bash
+sudo pacman -S pipewire pipewire-pulse pipewire-alsa pipewire-jack
+sudo pacman -S base-devel pkg-config
+```
+
+### Installing dependencies (Ubuntu/Debian)
+
+```bash
+sudo apt install pipewire pipewire-pulse pipewire-alsa
+sudo apt install libpipewire-0.3-dev pkg-config build-essential
+```
+
+## Development
+
+### Project Structure
+
+```
+src/
+β”œβ”€β”€ video/
+β”‚ β”œβ”€β”€ pipewire.rs # PipeWire virtual camera implementation
+β”‚ β”œβ”€β”€ v4l2.rs # V4L2 backend (future use)
+β”‚ β”œβ”€β”€ stdout.rs # Stdout video output backend
+β”‚ └── mod.rs # Video backend abstraction
+β”œβ”€β”€ usb/ # USB communication
+β”œβ”€β”€ protocol/ # UPP protocol implementation
+└── lib.rs # Main library interface
+```
+
+### Adding new video formats
+
+To add support for additional video formats:
+
+1. Extend the `VideoFormat` enum in `src/video/mod.rs`
+2. Update the PipeWire backend to handle format negotiation
+3. Implement proper SPA POD creation for the new format
+
+### Extending the virtual camera
+
+The PipeWire backend is designed to be extensible:
+
+- **Format negotiation**: Respond to client format requests
+- **Buffer management**: Handle buffer allocation and deallocation
+- **Frame pushing**: Implement actual video frame delivery
+- **Metadata**: Add custom metadata support
+
+## License
+
+This project is licensed under the CC0-1.0 License - see the [LICENSE](LICENSE) file for details.
+
+## Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+## Acknowledgments
+
+- PipeWire project for the excellent audio/video framework
+- The Rust PipeWire bindings maintainers
+- The original Geek Szitman SuperCamera project
diff --git a/benches/jpeg_parsing.rs b/benches/jpeg_parsing.rs
new file mode 100644
index 0000000..6d3e4fc
--- /dev/null
+++ b/benches/jpeg_parsing.rs
@@ -0,0 +1,57 @@
+//! Benchmark for JPEG parsing performance
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion};
+use geek_szitman_supercamera::protocol::jpeg::JpegParser;
+
+// Sample JPEG data for benchmarking
+const SAMPLE_JPEG: &[u8] = &[
+ 0xFF, 0xD8, // SOI
+ 0xFF, 0xE0, // APP0
+ 0x00, 0x10, // Length
+ 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
+ 0x01, 0x01, // Version
+ 0x00, // Units
+ 0x00, 0x01, // Density
+ 0x00, 0x01, 0x00, 0x00, // No thumbnail
+ 0xFF, 0xC0, // SOF0
+ 0x00, 0x0B, // Length
+ 0x08, // Precision
+ 0x00, 0x40, // Height (64)
+ 0x00, 0x40, // Width (64)
+ 0x03, // Components
+ 0x01, 0x11, 0x00, // Y component
+ 0x02, 0x11, 0x01, // U component
+ 0x03, 0x11, 0x01, // V component
+ 0xFF, 0xD9, // EOI
+];
+
+fn benchmark_jpeg_parsing(c: &mut Criterion) {
+ let parser = JpegParser::new();
+
+ c.bench_function("parse_jpeg_dimensions", |b| {
+ b.iter(|| parser.parse_dimensions(black_box(SAMPLE_JPEG)))
+ });
+
+ c.bench_function("parse_jpeg_metadata", |b| {
+ b.iter(|| parser.parse_metadata(black_box(SAMPLE_JPEG)))
+ });
+
+ c.bench_function("validate_jpeg", |b| {
+ b.iter(|| parser.validate_jpeg(black_box(SAMPLE_JPEG)))
+ });
+}
+
+fn benchmark_jpeg_parser_creation(c: &mut Criterion) {
+ c.bench_function("create_jpeg_parser", |b| b.iter(|| JpegParser::new()));
+
+ c.bench_function("create_jpeg_parser_with_debug", |b| {
+ b.iter(|| JpegParser::new_with_debug(true))
+ });
+}
+
+criterion_group!(
+ benches,
+ benchmark_jpeg_parsing,
+ benchmark_jpeg_parser_creation
+);
+criterion_main!(benches);
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..f101a61
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,247 @@
+//! Error types for the geek-szitman-supercamera crate
+
+use thiserror::Error;
+
+/// Result type for the crate
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Main error type for the crate
+#[derive(Error, Debug)]
+pub enum Error {
+ /// USB communication errors
+ #[error("USB error: {0}")]
+ Usb(#[from] UsbError),
+
+ /// Video backend errors
+ #[error("Video backend error: {0}")]
+ Video(#[from] VideoError),
+
+ /// Protocol errors
+ #[error("Protocol error: {0}")]
+ Protocol(#[from] ProtocolError),
+
+ /// JPEG processing errors
+ #[error("JPEG error: {0}")]
+ Jpeg(#[from] JpegError),
+
+ /// System errors
+ #[error("System error: {0}")]
+ System(#[from] SystemError),
+
+ /// Generic error wrapper
+ #[error("Generic error: {0}")]
+ Generic(String),
+}
+
+/// USB-specific errors
+#[derive(Error, Debug)]
+pub enum UsbError {
+ /// Device not found
+ #[error("USB device not found")]
+ DeviceNotFound,
+
+ /// Device disconnected
+ #[error("USB device disconnected")]
+ DeviceDisconnected,
+
+ /// Permission denied
+ #[error("USB permission denied")]
+ PermissionDenied,
+
+ /// Interface claim failed
+ #[error("Failed to claim USB interface: {0}")]
+ InterfaceClaimFailed(String),
+
+ /// Bulk transfer failed
+ #[error("USB bulk transfer failed: {0}")]
+ BulkTransferFailed(String),
+
+ /// Timeout error
+ #[error("USB operation timed out")]
+ Timeout,
+
+ /// Generic USB error
+ #[error("USB error: {0}")]
+ Generic(String),
+}
+
+impl UsbError {
+ /// Check if this error indicates device disconnection
+ pub fn is_device_disconnected(&self) -> bool {
+ matches!(self, Self::DeviceDisconnected)
+ }
+}
+
+/// Video backend errors
+#[derive(Error, Debug)]
+pub enum VideoError {
+ /// PipeWire errors
+ #[error("PipeWire error: {0}")]
+ PipeWire(String),
+
+ /// V4L2 errors (for future use)
+ #[error("V4L2 error: {0}")]
+ V4L2(String),
+
+ /// Stdout errors
+ #[error("Stdout error: {0}")]
+ Stdout(String),
+
+ /// Format not supported
+ #[error("Video format not supported: {0}")]
+ FormatNotSupported(String),
+
+ /// Device initialization failed
+ #[error("Video device initialization failed: {0}")]
+ InitializationFailed(String),
+
+ /// Frame push failed
+ #[error("Failed to push frame: {0}")]
+ FramePushFailed(String),
+
+ /// Device not ready
+ #[error("Device not ready")]
+ DeviceNotReady,
+}
+
+/// Protocol errors
+#[derive(Error, Debug)]
+pub enum ProtocolError {
+ /// Invalid frame format
+ #[error("Invalid frame format: {0}")]
+ InvalidFrameFormat(String),
+
+ /// Frame too small
+ #[error(
+ "Frame too small: expected at least {} bytes, got {}",
+ expected,
+ actual
+ )]
+ FrameTooSmall { expected: usize, actual: usize },
+
+ /// Invalid magic number
+ #[error(
+ "Invalid magic number: expected 0x{:04X}, got 0x{:04X}",
+ expected,
+ actual
+ )]
+ InvalidMagic { expected: u16, actual: u16 },
+
+ /// Unknown camera ID
+ #[error("Unknown camera ID: {0}")]
+ UnknownCameraId(u8),
+
+ /// Frame length mismatch
+ #[error("Frame length mismatch: expected {}, got {}", expected, actual)]
+ FrameLengthMismatch { expected: usize, actual: usize },
+
+ /// Protocol parsing error
+ #[error("Protocol parsing error: {0}")]
+ ParsingError(String),
+}
+
+/// JPEG processing errors
+#[derive(Error, Debug)]
+pub enum JpegError {
+ /// Invalid JPEG header
+ #[error("Invalid JPEG header")]
+ InvalidHeader,
+
+ /// Unsupported JPEG format
+ #[error("Unsupported JPEG format: {0}")]
+ UnsupportedFormat(String),
+
+ /// JPEG parsing failed
+ #[error("JPEG parsing failed: {0}")]
+ ParsingFailed(String),
+
+ /// Image dimensions not found
+ #[error("Could not determine image dimensions")]
+ DimensionsNotFound,
+}
+
+/// System errors
+#[derive(Error, Debug)]
+pub enum SystemError {
+ /// File operation failed
+ #[error("File operation failed: {0}")]
+ FileError(String),
+
+ /// Permission denied
+ #[error("Permission denied: {0}")]
+ PermissionDenied(String),
+
+ /// Resource not available
+ #[error("Resource not available: {0}")]
+ ResourceNotAvailable(String),
+
+ /// Signal handling error
+ #[error("Signal handling error: {0}")]
+ SignalError(String),
+}
+
+impl From<std::io::Error> for Error {
+ fn from(err: std::io::Error) -> Self {
+ match err.kind() {
+ std::io::ErrorKind::NotFound => Error::Usb(UsbError::DeviceNotFound),
+ std::io::ErrorKind::PermissionDenied => Error::Usb(UsbError::PermissionDenied),
+ std::io::ErrorKind::TimedOut => Error::Usb(UsbError::Timeout),
+ _ => Error::System(SystemError::FileError(err.to_string())),
+ }
+ }
+}
+
+impl From<rusb::Error> for Error {
+ fn from(err: rusb::Error) -> Self {
+ match err {
+ rusb::Error::NoDevice => Error::Usb(UsbError::DeviceDisconnected),
+ rusb::Error::Access => Error::Usb(UsbError::PermissionDenied),
+ rusb::Error::Timeout => Error::Usb(UsbError::Timeout),
+ rusb::Error::NotFound => Error::Usb(UsbError::DeviceNotFound),
+ _ => Error::Usb(UsbError::Generic(err.to_string())),
+ }
+ }
+}
+
+impl From<String> for Error {
+ fn from(err: String) -> Self {
+ Error::Generic(err)
+ }
+}
+
+impl From<&str> for Error {
+ fn from(err: &str) -> Self {
+ Error::Generic(err.to_string())
+ }
+}
+
+impl From<crate::usb::UsbTransferError> for Error {
+ fn from(err: crate::usb::UsbTransferError) -> Self {
+ Error::Usb(UsbError::Generic(err.to_string()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_usb_error_is_device_disconnected() {
+ let err = UsbError::DeviceDisconnected;
+ assert!(err.is_device_disconnected());
+
+ let err = UsbError::DeviceNotFound;
+ assert!(!err.is_device_disconnected());
+ }
+
+ #[test]
+ fn test_error_conversion() {
+ let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
+ let err: Error = io_err.into();
+ assert!(matches!(err, Error::Usb(UsbError::DeviceNotFound)));
+
+ let usb_err = rusb::Error::NoDevice;
+ let err: Error = usb_err.into();
+ assert!(matches!(err, Error::Usb(UsbError::DeviceDisconnected)));
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..ab42e1c
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,217 @@
+//! Geek szitman supercamera - Rust implementation
+//!
+//! This crate provides a Rust implementation of the Geek szitman supercamera
+//! endoscope viewer with PipeWire support and preparation for V4L2 fallback.
+//!
+//! # Features
+//!
+//! - USB communication with the endoscope device
+//! - PipeWire video streaming
+//! - UPP protocol implementation
+//! - JPEG frame processing
+//! - Modular architecture for maintainability
+//!
+//! # Example
+//!
+//! ```rust,no_run
+//! use geek_szitman_supercamera::SuperCamera;
+//!
+//! fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! let camera = SuperCamera::new()?;
+//! camera.start_stream()?;
+//! Ok(())
+//! }
+//! ```
+
+pub mod error;
+pub mod protocol;
+pub mod usb;
+pub mod utils;
+pub mod video;
+
+pub use error::{Error, Result};
+pub use protocol::UPPCamera;
+pub use usb::UsbSupercamera;
+pub use video::{VideoBackend, VideoBackendTrait};
+
+use std::sync::Arc;
+use std::sync::Mutex;
+use std::sync::RwLock;
+use std::thread;
+use std::time::Duration;
+use tracing::{info, warn};
+
+/// Main camera controller that orchestrates all components
+pub struct SuperCamera {
+ usb_camera: Arc<UsbSupercamera>,
+ protocol: Arc<UPPCamera>,
+ video_backend: Arc<Mutex<Box<dyn VideoBackendTrait>>>,
+ is_running: Arc<RwLock<bool>>,
+ usb_thread: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
+}
+
+impl SuperCamera {
+ /// Create a new SuperCamera instance
+ pub fn new() -> Result<Self> {
+ Self::with_backend(crate::video::VideoBackendType::PipeWire)
+ }
+
+ /// Create a new SuperCamera instance with specified backend
+ pub fn with_backend(backend_type: crate::video::VideoBackendType) -> Result<Self> {
+ let usb_camera = Arc::new(UsbSupercamera::new()?);
+ let protocol = Arc::new(UPPCamera::new_with_debug(true)); // Enable debug by default
+
+ // Initialize video backend based on choice
+ let video_backend = Arc::new(Mutex::new(VideoBackend::from_type(backend_type)?));
+
+ Ok(Self {
+ usb_camera,
+ protocol,
+ video_backend,
+ is_running: Arc::new(RwLock::new(false)),
+ usb_thread: Arc::new(Mutex::new(None)),
+ })
+ }
+
+ /// Start the camera stream
+ pub fn start_stream(&self) -> Result<()> {
+ let mut is_running = self.is_running.write().unwrap();
+ if *is_running {
+ warn!("Camera stream is already running");
+ return Ok(());
+ }
+
+ info!("Starting camera stream...");
+ *is_running = true;
+ drop(is_running);
+
+ // Ensure video backend is initialized before pushing frames
+ {
+ let mut backend = self.video_backend.lock().unwrap();
+ backend.initialize()?;
+ }
+
+ // Start USB reading loop in a separate thread
+ let usb_camera = Arc::clone(&self.usb_camera);
+ let protocol = Arc::clone(&self.protocol);
+ let video_backend = Arc::clone(&self.video_backend);
+ let is_running = Arc::clone(&self.is_running);
+
+ let handle = thread::spawn(move || {
+ Self::usb_read_loop(usb_camera, protocol, video_backend, is_running);
+ });
+
+ // Store the thread handle
+ let mut usb_thread = self.usb_thread.lock().unwrap();
+ *usb_thread = Some(handle);
+
+ Ok(())
+ }
+
+ /// Stop the camera stream
+ pub fn stop_stream(&self) -> Result<()> {
+ let mut is_running = self.is_running.write().unwrap();
+ if !*is_running {
+ warn!("Camera stream is not running");
+ return Ok(());
+ }
+
+ info!("Stopping camera stream...");
+ *is_running = false;
+ Ok(())
+ }
+
+ /// Check if the camera stream is running
+ pub fn is_running(&self) -> bool {
+ *self.is_running.read().unwrap()
+ }
+
+ /// Main USB reading loop
+ fn usb_read_loop(
+ usb_camera: Arc<UsbSupercamera>,
+ protocol: Arc<UPPCamera>,
+ video_backend: Arc<Mutex<Box<dyn VideoBackendTrait>>>,
+ is_running: Arc<RwLock<bool>>,
+ ) {
+ let mut frame_count = 0u32;
+
+ while *is_running.read().unwrap() {
+ match usb_camera.read_frame() {
+ Ok(data) => {
+ frame_count += 1;
+ // Reduce logging frequency - only log every 100th frame
+ if frame_count % 100 == 0 {
+ tracing::debug!("Received frame {} ({} bytes)", frame_count, data.len());
+ }
+
+ // Process frame through protocol
+ if let Err(e) = protocol.handle_frame_robust(&data) {
+ tracing::error!("Protocol error: {}", e);
+
+ // Log additional frame information for debugging
+ if data.len() >= 5 {
+ let magic_bytes = [data[0], data[1]];
+ let magic = u16::from_le_bytes(magic_bytes);
+ let cid = if data.len() >= 3 { data[2] } else { 0 };
+ let length_bytes = if data.len() >= 5 { [data[3], data[4]] } else { [0, 0] };
+ let length = u16::from_le_bytes(length_bytes);
+
+ tracing::debug!(
+ "Frame header: magic=0x{:04X}, cid={}, length={}, actual_size={}",
+ magic, cid, length, data.len()
+ );
+ }
+
+ continue;
+ }
+
+ // Send to video backend if frame is complete
+ if let Some(frame) = protocol.get_complete_frame() {
+ let backend = video_backend.lock().unwrap();
+ if let Err(e) = backend.push_frame(&frame) {
+ tracing::error!("Video backend error: {}", e);
+ }
+ }
+ }
+ Err(e) => {
+ tracing::error!("USB read error: {}", e);
+ // Check if it's a USB error that indicates disconnection
+ if let crate::error::Error::Usb(usb_err) = &e {
+ if usb_err.is_device_disconnected() {
+ tracing::warn!("Device disconnected, stopping stream");
+ break;
+ }
+ }
+ // Use standard library sleep instead of tokio
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ }
+ }
+
+ info!("USB reading loop stopped");
+ }
+}
+
+impl Drop for SuperCamera {
+ fn drop(&mut self) {
+ // Stop the stream
+ if let Ok(mut is_running) = self.is_running.try_write() {
+ *is_running = false;
+ }
+
+ // Wait for USB thread to finish
+ if let Some(handle) = self.usb_thread.lock().unwrap().take() {
+ let _ = handle.join();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn test_super_camera_creation() {
+ // This test requires actual USB device, so we'll just test the structure
+ // In a real test environment, we'd use mocks
+ assert!(true);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..153b4e9
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,207 @@
+//! Main binary for the Geek szitman supercamera
+
+use clap::Parser;
+use geek_szitman_supercamera::{Error, SuperCamera, video};
+
+use tracing::{error, info, warn};
+use tracing_subscriber::{fmt, EnvFilter};
+
+#[derive(Parser)]
+#[command(
+ name = "geek-szitman-supercamera",
+ about = "Rust implementation of Geek szitman supercamera endoscope viewer",
+ version,
+ author
+)]
+struct Cli {
+ /// Enable debug logging
+ #[arg(short, long)]
+ debug: bool,
+
+ /// Enable verbose logging
+ #[arg(short, long)]
+ verbose: bool,
+
+ /// Video backend to use
+ #[arg(short, long, value_enum, default_value = "pipewire")]
+ backend: BackendChoice,
+
+ /// Output directory for saved frames
+ #[arg(short, long, default_value = "pics")]
+ output_dir: String,
+
+ /// Frame rate hint
+ #[arg(short, long, default_value = "30")]
+ fps: u32,
+
+ /// Exit automatically after N seconds (0 = run until Ctrl+C)
+ #[arg(long, default_value = "0")]
+ timeout_seconds: u64,
+}
+
+#[derive(Clone, clap::ValueEnum)]
+enum BackendChoice {
+ PipeWire,
+ V4L2,
+ Stdout,
+}
+
+impl std::fmt::Display for BackendChoice {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ BackendChoice::PipeWire => write!(f, "pipewire"),
+ BackendChoice::V4L2 => write!(f, "v4l2"),
+ BackendChoice::Stdout => write!(f, "stdout"),
+ }
+ }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+ // Parse command line arguments
+ let cli = Cli::parse();
+
+ // Initialize logging
+ init_logging(cli.debug, cli.verbose)?;
+
+ info!("Starting Geek szitman supercamera viewer");
+ info!("Backend: {}", cli.backend);
+ info!("Output directory: {}", cli.output_dir);
+ info!("Frame rate hint: {} fps", cli.fps);
+
+ // Create output directory
+ std::fs::create_dir_all(&cli.output_dir)?;
+
+ // Set up signal handling
+ let signal_handler = setup_signal_handling()?;
+
+ // Create camera instance with selected backend
+ let camera = match cli.backend {
+ BackendChoice::PipeWire => SuperCamera::with_backend(crate::video::VideoBackendType::PipeWire),
+ BackendChoice::V4L2 => SuperCamera::with_backend(crate::video::VideoBackendType::V4L2),
+ BackendChoice::Stdout => SuperCamera::with_backend(crate::video::VideoBackendType::Stdout),
+ };
+
+ let camera = match camera {
+ Ok(camera) => camera,
+ Err(e) => {
+ error!("Failed to create camera: {}", e);
+ return Err(e.into());
+ }
+ };
+
+ // Start camera stream
+ if let Err(e) = camera.start_stream() {
+ error!("Failed to start camera stream: {}", e);
+ return Err(e.into());
+ }
+
+ info!("Camera stream started successfully");
+ info!("Press Ctrl+C to stop");
+
+ // Wait for shutdown signal or optional timeout
+ if cli.timeout_seconds > 0 {
+ let timed_out = !signal_handler
+ .wait_for_shutdown_with_timeout(std::time::Duration::from_secs(cli.timeout_seconds));
+ if timed_out {
+ info!("Timeout reached ({}s), initiating shutdown...", cli.timeout_seconds);
+ }
+ } else {
+ signal_handler.wait_for_shutdown();
+ }
+
+ info!("Shutting down...");
+
+ // Stop camera stream
+ if let Err(e) = camera.stop_stream() {
+ warn!("Error stopping camera stream: {}", e);
+ }
+
+ info!("Shutdown complete");
+ Ok(())
+}
+
+/// Initialize logging system
+fn init_logging(debug: bool, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
+ let filter = if verbose {
+ "geek_szitman_supercamera=trace"
+ } else if debug {
+ "geek_szitman_supercamera=debug"
+ } else {
+ "geek_szitman_supercamera=info"
+ };
+
+ let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter));
+
+ fmt::Subscriber::builder()
+ .with_env_filter(env_filter)
+ .with_target(false)
+ .with_thread_ids(false)
+ .with_thread_names(false)
+ .with_file(false)
+ .with_line_number(false)
+ .init();
+
+ Ok(())
+}
+
+/// Set up signal handling for graceful shutdown
+fn setup_signal_handling(
+) -> Result<geek_szitman_supercamera::utils::SignalHandler, Box<dyn std::error::Error>> {
+ geek_szitman_supercamera::utils::SignalHandler::new()
+}
+
+/// Handle errors gracefully
+fn handle_error(error: Error) {
+ match error {
+ Error::Usb(e) => {
+ error!("USB error: {}", e);
+ match e {
+ geek_szitman_supercamera::error::UsbError::DeviceNotFound => {
+ eprintln!("Device not found. Please check that the camera is connected.");
+ }
+ geek_szitman_supercamera::error::UsbError::PermissionDenied => {
+ eprintln!("Permission denied. Try running with sudo or add udev rules.");
+ }
+ geek_szitman_supercamera::error::UsbError::DeviceDisconnected => {
+ eprintln!("Device disconnected.");
+ }
+ _ => {
+ eprintln!("USB error: {e}");
+ }
+ }
+ }
+ Error::Video(e) => {
+ error!("Video backend error: {}", e);
+ match e {
+ geek_szitman_supercamera::error::VideoError::PipeWire(msg) => {
+ eprintln!("PipeWire error: {msg}. Make sure PipeWire is running.");
+ }
+ geek_szitman_supercamera::error::VideoError::V4L2(msg) => {
+ eprintln!("V4L2 error: {msg}. Make sure v4l2loopback is loaded.");
+ }
+ geek_szitman_supercamera::error::VideoError::Stdout(msg) => {
+ eprintln!("Stdout error: {msg}. Check if stdout is writable.");
+ }
+ _ => {
+ eprintln!("Video error: {e}");
+ }
+ }
+ }
+ Error::Protocol(e) => {
+ error!("Protocol error: {}", e);
+ eprintln!("Protocol error: {e}. Check camera firmware version.");
+ }
+ Error::Jpeg(e) => {
+ error!("JPEG error: {}", e);
+ eprintln!("JPEG processing error: {e}");
+ }
+ Error::System(e) => {
+ error!("System error: {}", e);
+ eprintln!("System error: {e}");
+ }
+ Error::Generic(msg) => {
+ error!("Generic error: {}", msg);
+ eprintln!("Error: {msg}");
+ }
+ }
+}
diff --git a/src/protocol/frame.rs b/src/protocol/frame.rs
new file mode 100644
index 0000000..32475e2
--- /dev/null
+++ b/src/protocol/frame.rs
@@ -0,0 +1,259 @@
+//! UPP protocol frame structures
+
+use serde::{Deserialize, Serialize};
+use std::mem;
+
+/// UPP USB frame header (5 bytes)
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[repr(C, packed)]
+pub struct UPPUsbFrame {
+ pub magic: u16, // 0xBBAA
+ pub cid: u8, // Camera ID
+ pub length: u16, // Data length (excluding header)
+}
+
+/// UPP camera frame header (7 bytes)
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
+#[repr(C, packed)]
+pub struct UPPFrameHeader {
+ pub frame_id: u8, // Frame ID
+ pub camera_number: u8, // Camera number
+ pub flags: UPPFlags, // Various flags
+ pub g_sensor: u32, // G-sensor data
+}
+
+/// UPP frame flags
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
+#[repr(C, packed)]
+pub struct UPPFlags {
+ pub has_g: bool, // Has G-sensor data (bit 0)
+ pub button_press: bool, // Button press detected (bit 1)
+ pub other: u8, // Other flags (bits 2-7, 6 bits total)
+}
+
+/// Complete UPP frame
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct UPPFrame {
+ pub header: UPPFrameHeader,
+ pub data: Vec<u8>,
+}
+
+impl UPPUsbFrame {
+ /// Create a new UPP USB frame
+ pub fn new(cid: u8, length: u16) -> Self {
+ Self {
+ magic: super::UPP_USB_MAGIC,
+ cid,
+ length,
+ }
+ }
+
+ /// Get the total frame size including header
+ /// Based on C++ POC: length field includes camera header + data, but not USB header
+ pub fn total_size(&self) -> usize {
+ mem::size_of::<Self>() + self.length as usize
+ }
+
+ /// Validate the frame magic number
+ pub fn is_valid(&self) -> bool {
+ self.magic == super::UPP_USB_MAGIC
+ }
+
+ /// Get the expected payload size (camera header + data)
+ /// Based on C++ POC: this is what the length field represents
+ pub fn expected_payload_size(&self) -> usize {
+ self.length as usize
+ }
+
+ /// Get the expected data size (excluding camera header)
+ /// Camera header is 7 bytes, so data size is payload - 7
+ pub fn expected_data_size(&self) -> usize {
+ if self.length >= 7 {
+ self.length as usize - 7
+ } else {
+ 0
+ }
+ }
+}
+
+impl UPPFrameHeader {
+ /// Create a new UPP frame header
+ pub fn new(
+ frame_id: u8,
+ camera_number: u8,
+ has_g: bool,
+ button_press: bool,
+ g_sensor: u32,
+ ) -> Self {
+ Self {
+ frame_id,
+ camera_number,
+ flags: UPPFlags {
+ has_g,
+ button_press,
+ other: 0,
+ },
+ g_sensor,
+ }
+ }
+
+ /// Check if this frame has G-sensor data
+ pub fn has_g_sensor(&self) -> bool {
+ self.flags.has_g
+ }
+
+ /// Check if button press was detected
+ pub fn button_pressed(&self) -> bool {
+ self.flags.button_press
+ }
+
+ /// Get other flags
+ pub fn other_flags(&self) -> u8 {
+ self.flags.other
+ }
+
+ /// Set other flags
+ pub fn set_other_flags(&mut self, flags: u8) {
+ self.flags.other = flags & 0x3F; // Only 6 bits
+ }
+
+ /// Get G-sensor data
+ pub fn g_sensor_data(&self) -> Option<u32> {
+ if self.has_g_sensor() {
+ Some(self.g_sensor)
+ } else {
+ None
+ }
+ }
+}
+
+impl UPPFrame {
+ /// Create a new UPP frame
+ pub fn new(header: UPPFrameHeader, data: Vec<u8>) -> Self {
+ Self { header, data }
+ }
+
+ /// Get the total frame size
+ pub fn total_size(&self) -> usize {
+ mem::size_of::<UPPFrameHeader>() + self.data.len()
+ }
+
+ /// Get the frame ID
+ pub fn frame_id(&self) -> u8 {
+ self.header.frame_id
+ }
+
+ /// Get the camera number
+ pub fn camera_number(&self) -> u8 {
+ self.header.camera_number
+ }
+
+ /// Check if button was pressed
+ pub fn button_pressed(&self) -> bool {
+ self.header.button_pressed()
+ }
+
+ /// Get G-sensor data if available
+ pub fn g_sensor_data(&self) -> Option<u32> {
+ if self.header.has_g_sensor() {
+ Some(self.header.g_sensor)
+ } else {
+ None
+ }
+ }
+}
+
+impl Default for UPPUsbFrame {
+ fn default() -> Self {
+ Self {
+ magic: super::UPP_USB_MAGIC,
+ cid: super::UPP_CAMID_7,
+ length: 0,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_upp_usb_frame_creation() {
+ let frame = UPPUsbFrame::new(7, 1024);
+ let magic = frame.magic;
+ let cid = frame.cid;
+ let length = frame.length;
+ assert_eq!(magic, super::super::UPP_USB_MAGIC);
+ assert_eq!(cid, 7);
+ assert_eq!(length, 1024);
+ assert!(frame.is_valid());
+ }
+
+ #[test]
+ fn test_upp_usb_frame_validation() {
+ let mut frame = UPPUsbFrame::new(7, 1024);
+ assert!(frame.is_valid());
+
+ frame.magic = 0x1234;
+ assert!(!frame.is_valid());
+ }
+
+ #[test]
+ fn test_upp_frame_header_creation() {
+ let header = UPPFrameHeader::new(1, 0, true, false, 12345);
+ assert_eq!(header.frame_id, 1);
+ assert_eq!(header.camera_number, 0);
+ assert!(header.has_g_sensor());
+ assert!(!header.button_pressed());
+ assert_eq!(header.g_sensor_data(), Some(12345));
+ }
+
+ #[test]
+ fn test_upp_frame_header_flags() {
+ let mut header = UPPFrameHeader::default();
+ assert!(!header.has_g_sensor());
+ assert!(!header.button_pressed());
+
+ header.flags.has_g = true;
+ header.flags.button_press = true;
+ assert!(header.has_g_sensor());
+ assert!(header.button_pressed());
+ }
+
+ #[test]
+ fn test_upp_frame_creation() {
+ let header = UPPFrameHeader::new(1, 0, false, false, 0);
+ let data = vec![1, 2, 3, 4, 5];
+ let frame = UPPFrame::new(header, data.clone());
+
+ assert_eq!(frame.frame_id(), 1);
+ assert_eq!(frame.camera_number(), 0);
+ assert_eq!(frame.data, data);
+ assert_eq!(frame.total_size(), mem::size_of::<UPPFrameHeader>() + 5);
+ }
+
+ #[test]
+ fn test_upp_frame_defaults() {
+ let frame = UPPFrame::default();
+ assert_eq!(frame.frame_id(), 0);
+ assert_eq!(frame.camera_number(), 0);
+ assert!(frame.data.is_empty());
+ assert!(!frame.button_pressed());
+ assert!(frame.g_sensor_data().is_none());
+ }
+
+ #[test]
+ fn test_upp_flags_other_bits() {
+ let mut header = UPPFrameHeader::default();
+ header.set_other_flags(0xFF);
+ assert_eq!(header.other_flags(), 0x3F); // Only 6 bits should be set
+ }
+
+ #[test]
+ fn test_memory_layout() {
+ // Ensure packed structs have correct sizes
+ assert_eq!(mem::size_of::<UPPUsbFrame>(), 5);
+ // UPPFrameHeader: frame_id(1) + camera_number(1) + flags(3) + g_sensor(4) = 9 bytes
+ assert_eq!(mem::size_of::<UPPFrameHeader>(), 9);
+ }
+}
diff --git a/src/protocol/jpeg.rs b/src/protocol/jpeg.rs
new file mode 100644
index 0000000..8398800
--- /dev/null
+++ b/src/protocol/jpeg.rs
@@ -0,0 +1,351 @@
+//! JPEG parsing utilities for the UPP protocol
+
+use crate::error::{JpegError, Result};
+use tracing::{debug, trace, warn};
+
+/// JPEG marker constants
+const JPEG_SOI: u8 = 0xD8; // Start of Image
+const JPEG_EOI: u8 = 0xD9; // End of Image
+const JPEG_SOS: u8 = 0xDA; // Start of Scan
+const JPEG_SOF0: u8 = 0xC0; // Start of Frame (Baseline DCT)
+const JPEG_SOF1: u8 = 0xC1; // Start of Frame (Extended sequential DCT)
+const JPEG_SOF2: u8 = 0xC2; // Start of Frame (Progressive DCT)
+
+/// JPEG image dimensions
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JpegDimensions {
+ pub width: u16,
+ pub height: u16,
+}
+
+impl JpegDimensions {
+ /// Create new dimensions
+ pub fn new(width: u16, height: u16) -> Self {
+ Self { width, height }
+ }
+
+ /// Check if dimensions are valid
+ pub fn is_valid(&self) -> bool {
+ self.width > 0 && self.height > 0
+ }
+
+ /// Get aspect ratio
+ pub fn aspect_ratio(&self) -> f64 {
+ if self.height > 0 {
+ self.width as f64 / self.height as f64
+ } else {
+ 0.0
+ }
+ }
+}
+
+/// JPEG parser for extracting metadata
+pub struct JpegParser {
+ enable_debug: bool,
+}
+
+impl JpegParser {
+ /// Create a new JPEG parser
+ pub fn new() -> Self {
+ Self {
+ enable_debug: false,
+ }
+ }
+
+ /// Create a new JPEG parser with debug enabled
+ pub fn new_with_debug(enable_debug: bool) -> Self {
+ Self { enable_debug }
+ }
+
+ /// Parse JPEG dimensions from raw data
+ pub fn parse_dimensions(&self, data: &[u8]) -> Result<JpegDimensions> {
+ if data.len() < 4 {
+ return Err(JpegError::InvalidHeader.into());
+ }
+
+ // Check JPEG SOI marker
+ if data[0] != 0xFF || data[1] != JPEG_SOI {
+ return Err(JpegError::InvalidHeader.into());
+ }
+
+ trace!("Parsing JPEG dimensions from {} bytes", data.len());
+
+ let mut i = 2;
+ while i + 3 < data.len() {
+ // Look for marker
+ if data[i] != 0xFF {
+ i += 1;
+ continue;
+ }
+
+ let marker = data[i + 1];
+ i += 2;
+
+ // Check for end markers
+ if marker == JPEG_EOI || marker == JPEG_SOS {
+ break;
+ }
+
+ // Check if we have enough data for segment length
+ if i + 1 >= data.len() {
+ break;
+ }
+
+ // Read segment length (big-endian)
+ let segment_length = ((data[i] as u16) << 8) | (data[i + 1] as u16);
+ if segment_length < 2 || i + segment_length as usize > data.len() {
+ warn!("Invalid segment length: {}", segment_length);
+ break;
+ }
+
+ // Check for SOF markers (Start of Frame)
+ if self.is_sof_marker(marker) {
+ if segment_length < 7 {
+ return Err(JpegError::InvalidHeader.into());
+ }
+
+ // Height and width are in big-endian format
+ let height = ((data[i + 3] as u16) << 8) | (data[i + 4] as u16);
+ let width = ((data[i + 5] as u16) << 8) | (data[i + 6] as u16);
+
+ if self.enable_debug {
+ debug!(
+ "Found SOF marker 0x{:02X}, dimensions: {}x{}",
+ marker, width, height
+ );
+ }
+
+ if width > 0 && height > 0 {
+ return Ok(JpegDimensions::new(width, height));
+ } else {
+ return Err(JpegError::DimensionsNotFound.into());
+ }
+ }
+
+ // Move to next segment
+ i += segment_length as usize;
+ }
+
+ Err(JpegError::DimensionsNotFound.into())
+ }
+
+ /// Check if a marker is a Start of Frame marker
+ fn is_sof_marker(&self, marker: u8) -> bool {
+ matches!(marker, JPEG_SOF0 | JPEG_SOF1 | JPEG_SOF2)
+ }
+
+ /// Extract JPEG metadata
+ pub fn parse_metadata(&self, data: &[u8]) -> Result<JpegMetadata> {
+ let dimensions = self.parse_dimensions(data)?;
+
+ Ok(JpegMetadata {
+ dimensions,
+ file_size: data.len(),
+ is_valid: true,
+ })
+ }
+
+ /// Validate JPEG data
+ pub fn validate_jpeg(&self, data: &[u8]) -> Result<bool> {
+ if data.len() < 4 {
+ return Ok(false);
+ }
+
+ // Check SOI marker
+ if data[0] != 0xFF || data[1] != JPEG_SOI {
+ return Ok(false);
+ }
+
+ // Check EOI marker (should be near the end)
+ if data.len() >= 2 {
+ let end = data.len() - 2;
+ if data[end] == 0xFF && data[end + 1] == JPEG_EOI {
+ return Ok(true);
+ }
+ }
+
+ // If we can't find EOI, check if we can parse dimensions
+ Ok(self.parse_dimensions(data).is_ok())
+ }
+
+ /// Check if data represents a complete JPEG frame
+ pub fn is_complete_jpeg(&self, data: &[u8]) -> bool {
+ if data.len() < 4 {
+ return false;
+ }
+
+ // Must start with SOI marker
+ if data[0] != 0xFF || data[1] != JPEG_SOI {
+ return false;
+ }
+
+ // Must end with EOI marker
+ if data.len() >= 2 {
+ let end = data.len() - 2;
+ if data[end] == 0xFF && data[end + 1] == JPEG_EOI {
+ return true;
+ }
+ }
+
+ false
+ }
+}
+
+impl Default for JpegParser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// JPEG metadata
+#[derive(Debug, Clone)]
+pub struct JpegMetadata {
+ pub dimensions: JpegDimensions,
+ pub file_size: usize,
+ pub is_valid: bool,
+}
+
+impl JpegMetadata {
+ /// Create new metadata
+ pub fn new(dimensions: JpegDimensions, file_size: usize) -> Self {
+ Self {
+ dimensions,
+ file_size,
+ is_valid: true,
+ }
+ }
+
+ /// Get estimated bit depth (assume 8-bit for most JPEGs)
+ pub fn estimated_bit_depth(&self) -> u8 {
+ 8
+ }
+
+ /// Get estimated color space (assume YUV for most JPEGs)
+ pub fn estimated_color_space(&self) -> &'static str {
+ "YUV"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::error::Error;
+
+ // Sample JPEG data for testing (minimal valid JPEG)
+ const MINIMAL_JPEG: &[u8] = &[
+ 0xFF, 0xD8, // SOI
+ 0xFF, 0xE0, // APP0
+ 0x00, 0x10, // Length
+ 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
+ 0x01, 0x01, // Version
+ 0x00, // Units
+ 0x00, 0x01, // Density
+ 0x00, 0x01, 0x00, 0x00, // No thumbnail
+ 0xFF, 0xC0, // SOF0
+ 0x00, 0x0B, // Length
+ 0x08, // Precision
+ 0x00, 0x40, // Height (64)
+ 0x00, 0x40, // Width (64)
+ 0x03, // Components
+ 0x01, 0x11, 0x00, // Y component
+ 0x02, 0x11, 0x01, // U component
+ 0x03, 0x11, 0x01, // V component
+ 0xFF, 0xD9, // EOI
+ ];
+
+ #[test]
+ fn test_jpeg_dimensions_creation() {
+ let dims = JpegDimensions::new(640, 480);
+ assert_eq!(dims.width, 640);
+ assert_eq!(dims.height, 480);
+ assert!(dims.is_valid());
+ assert_eq!(dims.aspect_ratio(), 640.0 / 480.0);
+ }
+
+ #[test]
+ fn test_jpeg_dimensions_validation() {
+ let dims = JpegDimensions::new(0, 480);
+ assert!(!dims.is_valid());
+
+ let dims = JpegDimensions::new(640, 0);
+ assert!(!dims.is_valid());
+
+ let dims = JpegDimensions::new(0, 0);
+ assert!(!dims.is_valid());
+ }
+
+ #[test]
+ fn test_jpeg_parser_creation() {
+ let parser = JpegParser::new();
+ assert!(!parser.enable_debug);
+
+ let parser = JpegParser::new_with_debug(true);
+ assert!(parser.enable_debug);
+ }
+
+ #[test]
+ fn test_jpeg_parser_parse_dimensions() {
+ let parser = JpegParser::new();
+ let dimensions = parser.parse_dimensions(MINIMAL_JPEG).unwrap();
+ assert_eq!(dimensions.width, 64);
+ assert_eq!(dimensions.height, 64);
+ }
+
+ #[test]
+ fn test_jpeg_parser_invalid_header() {
+ let parser = JpegParser::new();
+ let invalid_data = &[0x00, 0x01, 0x02, 0x03];
+
+ let result = parser.parse_dimensions(invalid_data);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ Error::Jpeg(JpegError::InvalidHeader)
+ ));
+ }
+
+ #[test]
+ fn test_jpeg_parser_short_data() {
+ let parser = JpegParser::new();
+ let short_data = &[0xFF, 0xD8];
+
+ let result = parser.parse_dimensions(short_data);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ Error::Jpeg(JpegError::InvalidHeader)
+ ));
+ }
+
+ #[test]
+ fn test_jpeg_parser_validate_jpeg() {
+ let parser = JpegParser::new();
+ assert!(parser.validate_jpeg(MINIMAL_JPEG).unwrap());
+
+ let invalid_data = &[0x00, 0x01, 0x02, 0x03];
+ assert!(!parser.validate_jpeg(invalid_data).unwrap());
+ }
+
+ #[test]
+ fn test_jpeg_metadata_creation() {
+ let dimensions = JpegDimensions::new(640, 480);
+ let metadata = JpegMetadata::new(dimensions, 1024);
+
+ assert_eq!(metadata.dimensions.width, 640);
+ assert_eq!(metadata.dimensions.height, 480);
+ assert_eq!(metadata.file_size, 1024);
+ assert!(metadata.is_valid);
+ assert_eq!(metadata.estimated_bit_depth(), 8);
+ assert_eq!(metadata.estimated_color_space(), "YUV");
+ }
+
+ #[test]
+ fn test_sof_marker_detection() {
+ let parser = JpegParser::new();
+ assert!(parser.is_sof_marker(JPEG_SOF0));
+ assert!(parser.is_sof_marker(JPEG_SOF1));
+ assert!(parser.is_sof_marker(JPEG_SOF2));
+ assert!(!parser.is_sof_marker(0x00));
+ assert!(!parser.is_sof_marker(JPEG_SOI));
+ }
+}
diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs
new file mode 100644
index 0000000..cf0426e
--- /dev/null
+++ b/src/protocol/mod.rs
@@ -0,0 +1,409 @@
+//! UPP protocol implementation for the Geek szitman supercamera
+
+mod frame;
+mod jpeg;
+mod parser;
+
+pub use frame::{UPPFrame, UPPFrameHeader, UPPUsbFrame};
+pub use jpeg::JpegParser;
+pub use parser::UPPParser;
+
+use crate::error::Result;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use std::sync::Mutex;
+use tracing::trace;
+
+/// UPP protocol constants
+pub const UPP_USB_MAGIC: u16 = 0xBBAA;
+pub const UPP_CAMID_7: u8 = 7;
+
+/// UPP camera instance
+pub struct UPPCamera {
+ parser: Arc<UPPParser>,
+ jpeg_parser: Arc<JpegParser>,
+ frame_buffer: Arc<Mutex<Vec<u8>>>,
+ current_frame_id: Arc<Mutex<Option<u8>>>,
+ frame_callbacks: Arc<Mutex<Vec<Box<dyn FrameCallback + Send + Sync>>>>,
+ button_callbacks: Arc<Mutex<Vec<Box<dyn ButtonCallback + Send + Sync>>>>,
+ // Buffer for assembling frames across USB reads
+ input_buffer: Arc<Mutex<Vec<u8>>>,
+}
+
+/// Frame callback trait
+pub trait FrameCallback {
+ /// Called when a complete frame is received
+ fn on_frame(&self, frame: &UPPFrame);
+}
+
+/// Button callback trait
+pub trait ButtonCallback {
+ /// Called when a button press is detected
+ fn on_button_press(&self);
+}
+
+impl UPPCamera {
+ /// Create a new UPP camera instance
+ pub fn new() -> Self {
+ Self {
+ parser: Arc::new(UPPParser::new()),
+ jpeg_parser: Arc::new(JpegParser::new()),
+ frame_buffer: Arc::new(Mutex::new(Vec::new())),
+ current_frame_id: Arc::new(Mutex::new(None)),
+ frame_callbacks: Arc::new(Mutex::new(Vec::new())),
+ button_callbacks: Arc::new(Mutex::new(Vec::new())),
+ input_buffer: Arc::new(Mutex::new(Vec::new())),
+ }
+ }
+
+ /// Create a new UPP camera instance with debug enabled
+ pub fn new_with_debug(enable_debug: bool) -> Self {
+ Self {
+ parser: Arc::new(UPPParser::new_with_debug(enable_debug)),
+ jpeg_parser: Arc::new(JpegParser::new_with_debug(enable_debug)),
+ frame_buffer: Arc::new(Mutex::new(Vec::new())),
+ current_frame_id: Arc::new(Mutex::new(None)),
+ frame_callbacks: Arc::new(Mutex::new(Vec::new())),
+ button_callbacks: Arc::new(Mutex::new(Vec::new())),
+ input_buffer: Arc::new(Mutex::new(Vec::new())),
+ }
+ }
+
+ /// Enable or disable debug mode
+ pub fn set_debug_mode(&self, enable: bool) {
+ // Create a new parser with the desired debug setting
+ let _new_parser = Arc::new(UPPParser::new_with_debug(enable));
+ // Replace the parser (this requires interior mutability or a different approach)
+ // For now, we'll just log the change
+ tracing::info!("Debug mode {} for UPP parser", if enable { "enabled" } else { "disabled" });
+ }
+}
+
+impl Default for UPPCamera {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl UPPCamera {
+ /// Handle incoming UPP frame data
+ pub fn handle_frame(&self, data: &[u8]) -> Result<()> {
+ // Reduce logging frequency - only log every 100th frame
+ static mut FRAME_COUNT: u32 = 0;
+ unsafe {
+ FRAME_COUNT += 1;
+ if FRAME_COUNT % 100 == 0 {
+ trace!("Handling frame data: {} bytes", data.len());
+ }
+ }
+
+ // Backward-compatible: feed bytes and process all parsed frames
+ for frame in self.feed_bytes(data)? {
+ self.process_frame(frame)?;
+ }
+
+ Ok(())
+ }
+
+ /// Handle incoming UPP frame data with better error handling
+ pub fn handle_frame_robust(&self, data: &[u8]) -> Result<()> {
+ trace!("Handling frame data robustly: {} bytes", data.len());
+
+ // Streaming-friendly: feed bytes and process all parsed frames
+ let frames = self.feed_bytes(data)?;
+ for frame in frames {
+ self.process_frame(frame)?;
+ }
+ Ok(())
+ }
+
+ /// Feed raw bytes from USB and extract as many complete protocol frames as possible.
+ /// Based on C++ POC: frames are chunked across multiple USB reads and need assembly.
+ pub fn feed_bytes(&self, chunk: &[u8]) -> Result<Vec<UPPFrame>> {
+ let mut out_frames = Vec::new();
+ let mut buffer = self.input_buffer.lock().unwrap();
+ buffer.extend_from_slice(chunk);
+
+ // Parse loop: find and assemble frames from buffer
+ loop {
+ // Need at least 5 bytes for USB header
+ if buffer.len() < 5 {
+ break;
+ }
+
+ // Search for magic 0xBBAA (little-endian)
+ let mut start_index = None;
+ for i in 0..=buffer.len() - 2 {
+ let magic = u16::from_le_bytes([buffer[i], buffer[i + 1]]);
+ if magic == UPP_USB_MAGIC {
+ start_index = Some(i);
+ break;
+ }
+ }
+
+ let Some(start) = start_index else {
+ // No magic found; drop buffer to avoid infinite growth
+ buffer.clear();
+ break;
+ };
+
+ // Drop any leading garbage before magic
+ if start > 0 {
+ buffer.drain(0..start);
+ }
+
+ // Re-check size for header
+ if buffer.len() < 5 {
+ break;
+ }
+
+ // Parse USB header fields directly from bytes
+ let _magic = u16::from_le_bytes([buffer[0], buffer[1]]);
+ let _cid = buffer[2];
+ let payload_length = u16::from_le_bytes([buffer[3], buffer[4]]) as usize;
+ let total_frame_size = 5 + payload_length; // USB header (5) + payload
+
+ if buffer.len() < total_frame_size {
+ // Wait for more data next call
+ break;
+ }
+
+ // We have a complete frame; extract it
+ let frame_bytes: Vec<u8> = buffer.drain(0..total_frame_size).collect();
+
+ // Parse the complete frame
+ match self.parser.parse_frame(&frame_bytes) {
+ Ok(frame) => {
+ out_frames.push(frame);
+ }
+ Err(e) => {
+ tracing::warn!("Failed to parse complete frame: {}", e);
+ // Continue processing other frames
+ }
+ }
+ }
+
+ Ok(out_frames)
+ }
+
+ /// Process a parsed UPP frame with frame assembly logic
+ /// Based on C++ POC: accumulate frame data until frame ID changes
+ fn process_frame(&self, frame: UPPFrame) -> Result<()> {
+ let mut frame_buffer = self.frame_buffer.lock().unwrap();
+ let mut current_frame_id = self.current_frame_id.lock().unwrap();
+
+ // Check if this is a new frame (frame ID changed)
+ if let Some(id) = *current_frame_id {
+ if id != frame.header.frame_id {
+ // New frame started, process the previous one
+ if !frame_buffer.is_empty() {
+ self.notify_frame_complete(&frame_buffer);
+ frame_buffer.clear();
+ }
+ }
+ }
+
+ // Update current frame ID
+ *current_frame_id = Some(frame.header.frame_id);
+
+ // Add frame data to buffer (accumulate chunks)
+ frame_buffer.extend_from_slice(&frame.data);
+
+ // Check for button press
+ if frame.header.flags.button_press {
+ self.notify_button_press();
+ }
+
+ Ok(())
+ }
+
+ /// Notify frame callbacks of complete frame
+ fn notify_frame_complete(&self, frame_data: &[u8]) {
+ let frame_callbacks = self.frame_callbacks.lock().unwrap();
+
+ for callback in frame_callbacks.iter() {
+ callback.on_frame(&UPPFrame {
+ header: UPPFrameHeader::new(0, 0, false, false, 0),
+ data: frame_data.to_vec(),
+ });
+ }
+ }
+
+ /// Notify button callbacks of button press
+ fn notify_button_press(&self) {
+ let button_callbacks = self.button_callbacks.lock().unwrap();
+
+ for callback in button_callbacks.iter() {
+ callback.on_button_press();
+ }
+ }
+
+ /// Add a frame callback
+ pub fn add_frame_callback<F>(&self, callback: F)
+ where
+ F: FrameCallback + Send + Sync + 'static,
+ {
+ let mut frame_callbacks = self.frame_callbacks.lock().unwrap();
+ frame_callbacks.push(Box::new(callback));
+ }
+
+ /// Add a button callback
+ pub fn add_button_callback<F>(&self, callback: F)
+ where
+ F: ButtonCallback + Send + Sync + 'static,
+ {
+ let mut button_callbacks = self.button_callbacks.lock().unwrap();
+ button_callbacks.push(Box::new(callback));
+ }
+
+ /// Get the complete frame if available
+ pub fn get_complete_frame(&self) -> Option<Vec<u8>> {
+ let frame_buffer = self.frame_buffer.lock().unwrap();
+ if frame_buffer.is_empty() {
+ None
+ } else {
+ // Check if the frame is a complete JPEG before returning it
+ if self.jpeg_parser.is_complete_jpeg(&frame_buffer) {
+ Some(frame_buffer.clone())
+ } else {
+ None
+ }
+ }
+ }
+
+ /// Get the current frame buffer size (for debugging frame assembly)
+ pub fn get_frame_buffer_size(&self) -> usize {
+ let frame_buffer = self.frame_buffer.lock().unwrap();
+ frame_buffer.len()
+ }
+
+ /// Get the current input buffer size (for debugging chunk accumulation)
+ pub fn get_input_buffer_size(&self) -> usize {
+ let input_buffer = self.input_buffer.lock().unwrap();
+ input_buffer.len()
+ }
+
+ /// Clear the frame buffer
+ pub fn clear_frame_buffer(&self) {
+ let mut frame_buffer = self.frame_buffer.lock().unwrap();
+ frame_buffer.clear();
+ }
+
+ /// Get frame statistics
+ pub fn get_stats(&self) -> UPPStats {
+ let frame_buffer = self.frame_buffer.lock().unwrap();
+ let current_frame_id = self.current_frame_id.lock().unwrap();
+
+ UPPStats {
+ buffer_size: frame_buffer.len(),
+ current_frame_id: *current_frame_id,
+ total_frames_processed: 0, // TODO: implement counter
+ }
+ }
+}
+
+/// UPP protocol statistics
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct UPPStats {
+ pub buffer_size: usize,
+ pub current_frame_id: Option<u8>,
+ pub total_frames_processed: u64,
+}
+
+/// UPP camera configuration
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UPPConfig {
+ pub expected_camera_id: u8,
+ pub max_frame_size: usize,
+ pub enable_debug: bool,
+}
+
+impl Default for UPPConfig {
+ fn default() -> Self {
+ Self {
+ expected_camera_id: UPP_CAMID_7,
+ max_frame_size: 0x10000, // 64KB
+ enable_debug: false,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ // Mock frame callback for testing
+ struct MockFrameCallback {
+ frame_count: Arc<std::sync::Mutex<u32>>,
+ }
+
+ impl FrameCallback for MockFrameCallback {
+ fn on_frame(&self, _frame: &UPPFrame) {
+ let mut count = self.frame_count.lock().unwrap();
+ *count += 1;
+ }
+ }
+
+ // Mock button callback for testing
+ struct MockButtonCallback {
+ press_count: Arc<std::sync::Mutex<u32>>,
+ }
+
+ impl ButtonCallback for MockButtonCallback {
+ fn on_button_press(&self) {
+ let mut count = self.press_count.lock().unwrap();
+ *count += 1;
+ }
+ }
+
+ #[test]
+ fn test_upp_constants() {
+ assert_eq!(UPP_USB_MAGIC, 0xBBAA);
+ assert_eq!(UPP_CAMID_7, 7);
+ }
+
+ #[test]
+ fn test_upp_config_default() {
+ let config = UPPConfig::default();
+ assert_eq!(config.expected_camera_id, UPP_CAMID_7);
+ assert_eq!(config.max_frame_size, 0x10000);
+ assert!(!config.enable_debug);
+ }
+
+ #[test]
+ fn test_upp_stats_default() {
+ let stats = UPPStats::default();
+ assert_eq!(stats.buffer_size, 0);
+ assert!(stats.current_frame_id.is_none());
+ assert_eq!(stats.total_frames_processed, 0);
+ }
+
+ #[test]
+ fn test_upp_camera_creation() {
+ let camera = UPPCamera::new();
+ let stats = camera.get_stats();
+ assert_eq!(stats.buffer_size, 0);
+ assert!(stats.current_frame_id.is_none());
+ }
+
+ #[test]
+ fn test_upp_camera_callbacks() {
+ let camera = UPPCamera::new();
+
+ let frame_callback = MockFrameCallback {
+ frame_count: Arc::new(std::sync::Mutex::new(0u32)),
+ };
+
+ let button_callback = MockButtonCallback {
+ press_count: Arc::new(std::sync::Mutex::new(0u32)),
+ };
+
+ camera.add_frame_callback(frame_callback);
+ camera.add_button_callback(button_callback);
+
+ // Verify callbacks were added
+ let stats = camera.get_stats();
+ assert_eq!(stats.buffer_size, 0);
+ }
+}
diff --git a/src/protocol/parser.rs b/src/protocol/parser.rs
new file mode 100644
index 0000000..7db824e
--- /dev/null
+++ b/src/protocol/parser.rs
@@ -0,0 +1,418 @@
+//! UPP protocol parser for decoding USB frames
+
+use super::frame::{UPPFlags, UPPFrame, UPPFrameHeader, UPPUsbFrame};
+use crate::error::{ProtocolError, Result};
+use std::mem;
+use tracing::{debug, trace, warn};
+
+/// UPP protocol parser
+pub struct UPPParser {
+ enable_debug: bool,
+}
+
+impl UPPParser {
+ /// Create a new UPP parser
+ pub fn new() -> Self {
+ Self {
+ enable_debug: false,
+ }
+ }
+
+ /// Create a new UPP parser with debug enabled
+ pub fn new_with_debug(enable_debug: bool) -> Self {
+ Self { enable_debug }
+ }
+
+ /// Parse a raw USB frame into a UPP frame
+ pub fn parse_frame(&self, data: &[u8]) -> Result<UPPFrame> {
+ trace!("Parsing UPP frame: {} bytes", data.len());
+
+ // Parse USB frame header
+ let usb_frame = self.parse_usb_header(data)?;
+
+ // Validate frame length - based on C++ POC analysis
+ let expected_total_size = usb_frame.total_size();
+ let actual_size = data.len();
+
+ if self.enable_debug {
+ trace!(
+ "Frame size validation: expected={}, actual={}, difference={}",
+ expected_total_size, actual_size, actual_size as i32 - expected_total_size as i32
+ );
+ }
+
+ // Based on C++ POC: the length field represents total payload size
+ // If we have less data than expected, this is a partial frame (chunked)
+ if actual_size < expected_total_size {
+ return Err(ProtocolError::FrameTooSmall {
+ expected: expected_total_size,
+ actual: actual_size,
+ }
+ .into());
+ }
+
+ // Parse camera frame header
+ let camera_header = self.parse_camera_header(data, &usb_frame)?;
+
+ // Extract frame data - use the expected data size
+ let data_start = mem::size_of::<UPPUsbFrame>() + 7; // Manual header size: 7 bytes
+ let frame_data = if data_start < data.len() {
+ let end_index = std::cmp::min(data_start + usb_frame.expected_data_size(), data.len());
+ data[data_start..end_index].to_vec()
+ } else {
+ Vec::new()
+ };
+
+ if self.enable_debug {
+ trace!(
+ "Parsed UPP frame: ID={}, Camera={}, Data={} bytes, Total={} bytes, Expected={} bytes",
+ camera_header.frame_id,
+ camera_header.camera_number,
+ frame_data.len(),
+ data.len(),
+ expected_total_size
+ );
+ }
+
+ Ok(UPPFrame {
+ header: camera_header,
+ data: frame_data,
+ })
+ }
+
+ /// Parse a raw USB frame with better error handling and diagnostics
+ pub fn parse_frame_robust(&self, data: &[u8]) -> Result<UPPFrame> {
+ trace!("Parsing UPP frame robustly: {} bytes", data.len());
+
+ // First, try to find a valid frame start
+ let frame_start = self.find_frame_start(data)?;
+ let frame_data = &data[frame_start..];
+
+ if self.enable_debug {
+ debug!("Found frame start at offset {}, processing {} bytes", frame_start, frame_data.len());
+ }
+
+ // Parse the frame from the found start position
+ self.parse_frame(frame_data)
+ }
+
+ /// Find the start of a valid UPP frame in potentially misaligned data
+ fn find_frame_start(&self, data: &[u8]) -> Result<usize> {
+ if data.len() < 12 { // Minimum frame size
+ return Err(ProtocolError::FrameTooSmall {
+ expected: 12,
+ actual: data.len(),
+ }
+ .into());
+ }
+
+ // Look for the magic number (0xBBAA) in the data
+ for i in 0..=data.len().saturating_sub(12) {
+ if data.len() >= i + 2 {
+ let magic_bytes = [data[i], data[i + 1]];
+ let magic = u16::from_le_bytes(magic_bytes);
+
+ if magic == super::UPP_USB_MAGIC {
+ if self.enable_debug {
+ debug!("Found magic number 0x{:04X} at offset {}", magic, i);
+ }
+ return Ok(i);
+ }
+ }
+ }
+
+ Err(ProtocolError::InvalidMagic {
+ expected: super::UPP_USB_MAGIC,
+ actual: 0, // Unknown
+ }
+ .into())
+ }
+
+ /// Parse USB frame header
+ pub(crate) fn parse_usb_header(&self, data: &[u8]) -> Result<UPPUsbFrame> {
+ if data.len() < mem::size_of::<UPPUsbFrame>() {
+ return Err(ProtocolError::FrameTooSmall {
+ expected: mem::size_of::<UPPUsbFrame>(),
+ actual: data.len(),
+ }
+ .into());
+ }
+
+ // Safe to transmute since we've checked the size
+ let usb_frame = unsafe { *(data.as_ptr() as *const UPPUsbFrame) };
+
+ // Validate magic number
+ if !usb_frame.is_valid() {
+ return Err(ProtocolError::InvalidMagic {
+ expected: super::UPP_USB_MAGIC,
+ actual: usb_frame.magic,
+ }
+ .into());
+ }
+
+ // Validate camera ID
+ if usb_frame.cid != super::UPP_CAMID_7 {
+ return Err(ProtocolError::UnknownCameraId(usb_frame.cid).into());
+ }
+
+ if self.enable_debug {
+ let magic = usb_frame.magic;
+ let cid = usb_frame.cid;
+ let length = usb_frame.length;
+ trace!(
+ "USB frame: magic=0x{:04X}, cid={}, length={}",
+ magic, cid, length
+ );
+ }
+
+ Ok(usb_frame)
+ }
+
+ /// Parse camera frame header
+ pub(crate) fn parse_camera_header(&self, data: &[u8], _usb_frame: &UPPUsbFrame) -> Result<UPPFrameHeader> {
+ let header_offset = mem::size_of::<UPPUsbFrame>();
+ // Manual header size: frame_id(1) + camera_number(1) + flags(1) + g_sensor(4) = 7 bytes
+ let header_end = header_offset + 7;
+
+ if data.len() < header_end {
+ return Err(ProtocolError::FrameTooSmall {
+ expected: header_end,
+ actual: data.len(),
+ }
+ .into());
+ }
+
+ // Manually parse the header to avoid packed struct issues
+ let frame_id = data[header_offset];
+ let camera_number = data[header_offset + 1];
+
+ // Read flags byte and extract individual bits
+ let flags_byte = data[header_offset + 2];
+ let has_g = (flags_byte & 0x01) != 0; // Bit 0
+ let button_press = (flags_byte & 0x02) != 0; // Bit 1
+ let other = (flags_byte >> 2) & 0x3F; // Bits 2-7 (6 bits)
+
+ if self.enable_debug {
+ trace!(
+ "Raw flags byte: 0x{:02X}, has_g={}, button_press={}, other=0x{:02X}",
+ flags_byte, has_g, button_press, other
+ );
+ }
+
+ // Read G-sensor data (4 bytes, little-endian)
+ let g_sensor_bytes = [
+ data[header_offset + 3],
+ data[header_offset + 4],
+ data[header_offset + 5],
+ data[header_offset + 6],
+ ];
+
+ if self.enable_debug {
+ trace!(
+ "G-sensor bytes: {:02X} {:02X} {:02X} {:02X}",
+ g_sensor_bytes[0], g_sensor_bytes[1], g_sensor_bytes[2], g_sensor_bytes[3]
+ );
+ }
+
+ let g_sensor = u32::from_le_bytes(g_sensor_bytes);
+
+ let camera_header = UPPFrameHeader {
+ frame_id,
+ camera_number,
+ flags: UPPFlags {
+ has_g,
+ button_press,
+ other,
+ },
+ g_sensor,
+ };
+
+ // Validate frame header
+ self.validate_camera_header(&camera_header)?;
+
+ if self.enable_debug {
+ trace!(
+ "Camera header: frame_id={}, camera={}, has_g={}, button={}",
+ camera_header.frame_id,
+ camera_header.camera_number,
+ camera_header.flags.has_g,
+ camera_header.flags.button_press
+ );
+ }
+
+ Ok(camera_header)
+ }
+
+ /// Validate camera frame header
+ fn validate_camera_header(&self, header: &UPPFrameHeader) -> Result<()> {
+ // Validate camera number (should be 0 or 1)
+ if header.camera_number >= 2 {
+ return Err(ProtocolError::InvalidFrameFormat(format!(
+ "Invalid camera number: {}",
+ header.camera_number
+ ))
+ .into());
+ }
+
+ // Validate flags (G-sensor and other flags should be 0 for now)
+ if header.flags.has_g {
+ warn!("G-sensor data not yet supported");
+ }
+
+ if header.flags.other != 0 {
+ warn!("Unknown flags set: 0x{:02X}", header.flags.other);
+ }
+
+ Ok(())
+ }
+
+ /// Get frame statistics
+ pub fn get_stats(&self) -> UPPParserStats {
+ UPPParserStats {
+ enable_debug: self.enable_debug,
+ }
+ }
+}
+
+impl Default for UPPParser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// UPP parser statistics
+#[derive(Debug, Clone)]
+pub struct UPPParserStats {
+ pub enable_debug: bool,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::UPP_USB_MAGIC;
+ use super::*;
+ use crate::error::Error;
+
+ // Create test data for a valid UPP frame
+ fn create_test_frame(frame_id: u8, camera_number: u8, data_size: usize) -> Vec<u8> {
+ let mut frame = Vec::new();
+
+ // USB header (5 bytes)
+ frame.extend_from_slice(&UPP_USB_MAGIC.to_le_bytes());
+ frame.push(7); // Camera ID
+ frame.extend_from_slice(&(data_size as u16 + 7).to_le_bytes()); // Length (camera header + data)
+
+ // Camera header (7 bytes)
+ frame.push(frame_id);
+ frame.push(camera_number);
+ // Flags struct (1 byte - packed bit fields)
+ let flags_byte = 0u8; // has_g: false, button_press: false, other: 0
+ frame.push(flags_byte);
+ frame.extend_from_slice(&0u32.to_le_bytes()); // G-sensor data (4 bytes)
+
+ // Frame data
+ frame.extend(std::iter::repeat(0xAA).take(data_size));
+
+ frame
+ }
+
+ #[test]
+ fn test_upp_parser_creation() {
+ let parser = UPPParser::new();
+ assert!(!parser.enable_debug);
+
+ let parser = UPPParser::new_with_debug(true);
+ assert!(parser.enable_debug);
+ }
+
+ #[test]
+ fn test_upp_parser_parse_valid_frame() {
+ let parser = UPPParser::new();
+ let test_frame = create_test_frame(1, 0, 10);
+
+ let result = parser.parse_frame(&test_frame);
+ assert!(result.is_ok());
+
+ let frame = result.unwrap();
+ assert_eq!(frame.frame_id(), 1);
+ assert_eq!(frame.camera_number(), 0);
+ assert_eq!(frame.data.len(), 10);
+ assert!(!frame.button_pressed());
+ assert!(frame.g_sensor_data().is_none());
+ }
+
+ #[test]
+ fn test_upp_parser_frame_too_small() {
+ let parser = UPPParser::new();
+ let short_data = &[0xFF, 0xAA]; // Too short for any header
+
+ let result = parser.parse_frame(short_data);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ Error::Protocol(ProtocolError::FrameTooSmall { .. })
+ ));
+ }
+
+ #[test]
+ fn test_upp_parser_invalid_magic() {
+ let parser = UPPParser::new();
+ let mut test_frame = create_test_frame(1, 0, 5);
+ test_frame[0] = 0x12; // Corrupt magic
+
+ let result = parser.parse_frame(&test_frame);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ Error::Protocol(ProtocolError::InvalidMagic { .. })
+ ));
+ }
+
+ #[test]
+ fn test_upp_parser_unknown_camera_id() {
+ let parser = UPPParser::new();
+ let mut test_frame = create_test_frame(1, 0, 5);
+ test_frame[2] = 5; // Unknown camera ID
+
+ let result = parser.parse_frame(&test_frame);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ Error::Protocol(ProtocolError::UnknownCameraId(5))
+ ));
+ }
+
+ #[test]
+ fn test_upp_parser_button_press() {
+ let parser = UPPParser::new();
+ let mut test_frame = create_test_frame(1, 0, 5);
+ test_frame[7] = 0x02; // Set button press flag (bit 1 of flags byte at index 7)
+
+ let result = parser.parse_frame(&test_frame);
+ assert!(result.is_ok());
+
+ let frame = result.unwrap();
+ assert!(frame.button_pressed());
+ }
+
+ #[test]
+ fn test_upp_parser_g_sensor() {
+ let parser = UPPParser::new();
+ let mut test_frame = create_test_frame(1, 0, 5);
+ test_frame[7] = 0x01; // Set G-sensor flag (bit 0 of flags byte at index 7)
+ test_frame[8..12].copy_from_slice(&0x12345678u32.to_le_bytes()); // G-sensor data (indices 8-11)
+
+ let result = parser.parse_frame(&test_frame);
+ assert!(result.is_ok());
+
+ let frame = result.unwrap();
+ assert!(frame.g_sensor_data().is_some());
+ assert_eq!(frame.g_sensor_data().unwrap(), 0x12345678);
+ }
+
+ #[test]
+ fn test_upp_parser_stats() {
+ let parser = UPPParser::new();
+ let stats = parser.get_stats();
+ assert!(!stats.enable_debug);
+ }
+}
diff --git a/src/usb/device.rs b/src/usb/device.rs
new file mode 100644
index 0000000..b285e81
--- /dev/null
+++ b/src/usb/device.rs
@@ -0,0 +1,287 @@
+//! USB device implementation for the Geek szitman supercamera
+
+use super::{ENDPOINT_1, ENDPOINT_2, INTERFACE_A_NUMBER, INTERFACE_B_NUMBER, INTERFACE_B_ALTERNATE_SETTING, USB_PRODUCT_ID, USB_TIMEOUT, USB_VENDOR_ID};
+use crate::error::{Result, UsbError};
+use rusb::{Context, Device, DeviceHandle, UsbContext};
+use std::sync::Arc;
+use std::sync::Mutex;
+use tracing::{debug, error, info, warn};
+
+/// USB device handle wrapper
+pub struct UsbSupercamera {
+ context: Arc<Context>,
+ handle: Arc<Mutex<Option<DeviceHandle<Context>>>>,
+ device_info: super::UsbDeviceInfo,
+}
+
+impl UsbSupercamera {
+ /// Create a new USB supercamera instance
+ pub fn new() -> Result<Self> {
+ let context = Arc::new(Context::new()?);
+ let handle = Arc::new(Mutex::new(None));
+ let device_info = super::UsbDeviceInfo::default();
+
+ let mut instance = Self {
+ context,
+ handle,
+ device_info,
+ };
+
+ instance.connect()?;
+ instance.initialize_device()?;
+
+ Ok(instance)
+ }
+
+ /// Connect to the USB device
+ fn connect(&mut self) -> Result<()> {
+ let device = self.find_device()?;
+ let handle = device.open()?;
+
+ // Ensure kernel drivers are detached when claiming interfaces
+ handle.set_auto_detach_kernel_driver(true)?;
+
+ let mut handle_guard = self.handle.try_lock()
+ .map_err(|_| UsbError::Generic("Failed to acquire handle lock".to_string()))?;
+ *handle_guard = Some(handle);
+
+ info!("Connected to USB device {:04x}:{:04x}", USB_VENDOR_ID, USB_PRODUCT_ID);
+ Ok(())
+ }
+
+ /// Find the target USB device
+ fn find_device(&self) -> Result<Device<Context>> {
+ for device in self.context.devices()?.iter() {
+ let device_desc = device.device_descriptor()?;
+
+ if device_desc.vendor_id() == USB_VENDOR_ID &&
+ device_desc.product_id() == USB_PRODUCT_ID {
+ debug!("Found target device: {:04x}:{:04x}", USB_VENDOR_ID, USB_PRODUCT_ID);
+ return Ok(device);
+ }
+ }
+
+ Err(UsbError::DeviceNotFound.into())
+ }
+
+ /// Initialize the USB device interfaces and endpoints
+ fn initialize_device(&self) -> Result<()> {
+ let handle_guard = self.handle.try_lock()
+ .map_err(|_| UsbError::Generic("Failed to acquire handle lock".to_string()))?;
+
+ let handle = handle_guard.as_ref()
+ .ok_or_else(|| UsbError::Generic("No device handle available".to_string()))?;
+
+ // Claim interface A
+ self.claim_interface(handle, INTERFACE_A_NUMBER)?;
+
+ // Claim interface B
+ self.claim_interface(handle, INTERFACE_B_NUMBER)?;
+
+ // Set alternate setting for interface B
+ self.set_interface_alt_setting(handle, INTERFACE_B_NUMBER, INTERFACE_B_ALTERNATE_SETTING)?;
+
+ // Clear halt on endpoint 1 (both directions)
+ self.clear_halt(handle, ENDPOINT_1 | 0x80)?; // IN (0x81)
+ self.clear_halt(handle, ENDPOINT_1)?; // OUT (0x01)
+
+ // Send initialization commands
+ self.send_init_commands(handle)?;
+
+ info!("USB device initialized successfully");
+ Ok(())
+ }
+
+ /// Claim a USB interface
+ fn claim_interface(&self, handle: &DeviceHandle<Context>, interface: u8) -> Result<()> {
+ match handle.claim_interface(interface) {
+ Ok(()) => {
+ debug!("Claimed interface {}", interface);
+ Ok(())
+ }
+ Err(e) => {
+ error!("Failed to claim interface {}: {}", interface, e);
+ Err(UsbError::InterfaceClaimFailed(e.to_string()).into())
+ }
+ }
+ }
+
+ /// Set interface alternate setting
+ fn set_interface_alt_setting(&self, handle: &DeviceHandle<Context>, interface: u8, setting: u8) -> Result<()> {
+ match handle.set_alternate_setting(interface, setting) {
+ Ok(()) => {
+ debug!("Set interface {} alternate setting to {}", interface, setting);
+ Ok(())
+ }
+ Err(e) => {
+ error!("Failed to set interface {} alternate setting {}: {}", interface, setting, e);
+ Err(UsbError::Generic(format!("Failed to set alternate setting: {e}")).into())
+ }
+ }
+ }
+
+ /// Clear halt on an endpoint
+ fn clear_halt(&self, handle: &DeviceHandle<Context>, endpoint: u8) -> Result<()> {
+ match handle.clear_halt(endpoint) {
+ Ok(()) => {
+ debug!("Cleared halt on endpoint {}", endpoint);
+ Ok(())
+ }
+ Err(e) => {
+ error!("Failed to clear halt on endpoint {}: {}", endpoint, e);
+ Err(UsbError::Generic(format!("Failed to clear halt: {e}")).into())
+ }
+ }
+ }
+
+ /// Send initialization commands to the device
+ fn send_init_commands(&self, handle: &DeviceHandle<Context>) -> Result<()> {
+ // Send magic words to endpoint 2
+ let ep2_buf = vec![0xFF, 0x55, 0xFF, 0x55, 0xEE, 0x10];
+ self.write_bulk(handle, ENDPOINT_2, &ep2_buf)?;
+
+ // Send start stream command to endpoint 1
+ let start_stream = vec![0xBB, 0xAA, 5, 0, 0];
+ self.write_bulk(handle, ENDPOINT_1, &start_stream)?;
+
+ debug!("Sent initialization commands");
+ Ok(())
+ }
+
+ /// Read a frame from the USB device
+ pub fn read_frame(&self) -> Result<Vec<u8>> {
+ let handle_guard = self.handle.lock().unwrap();
+ let handle = handle_guard.as_ref()
+ .ok_or_else(|| UsbError::Generic("No device handle available".to_string()))?;
+
+ // Use a larger buffer to handle potential frame buffering
+ let mut buffer = vec![0u8; 0x2000]; // Increased from 0x1000 to 0x2000
+ let transferred = self.read_bulk(handle, ENDPOINT_1, &mut buffer)?;
+
+ buffer.truncate(transferred);
+ // Reduce logging frequency - only log every 100th read
+ static mut READ_COUNT: u32 = 0;
+ unsafe {
+ READ_COUNT += 1;
+ if READ_COUNT % 100 == 0 {
+ debug!("Read {} bytes from USB device", transferred);
+ }
+ }
+
+ // Validate that we got a reasonable amount of data
+ if transferred < 12 { // Minimum frame size: USB header (5) + camera header (7)
+ return Err(UsbError::Generic(format!(
+ "Received frame too small: {} bytes (minimum: 12)",
+ transferred
+ )).into());
+ }
+
+ // Check if this looks like a valid UPP frame
+ if transferred >= 5 {
+ let magic_bytes = [buffer[0], buffer[1]];
+ let magic = u16::from_le_bytes(magic_bytes);
+ if magic != crate::protocol::UPP_USB_MAGIC {
+ warn!("Received data doesn't start with expected magic: 0x{:04X}", magic);
+ }
+ }
+
+ Ok(buffer)
+ }
+
+ /// Read bulk data from an endpoint
+ fn read_bulk(&self, handle: &DeviceHandle<Context>, endpoint: u8, buffer: &mut [u8]) -> Result<usize> {
+ let endpoint_address = endpoint | 0x80; // IN endpoint address bit
+
+ match handle.read_bulk(endpoint_address, buffer, USB_TIMEOUT) {
+ Ok(transferred) => {
+ debug!("Read {} bytes from endpoint {}", transferred, endpoint);
+ Ok(transferred)
+ }
+ Err(e) => {
+ error!("USB read error on endpoint {}: {}", endpoint, e);
+ match e {
+ rusb::Error::NoDevice => Err(UsbError::DeviceDisconnected.into()),
+ rusb::Error::Timeout => Err(UsbError::Timeout.into()),
+ _ => Err(UsbError::BulkTransferFailed(e.to_string()).into()),
+ }
+ }
+ }
+ }
+
+ /// Write bulk data to an endpoint
+ fn write_bulk(&self, handle: &DeviceHandle<Context>, endpoint: u8, data: &[u8]) -> Result<()> {
+ let endpoint_address = endpoint; // OUT endpoint has no direction bit set
+
+ match handle.write_bulk(endpoint_address, data, USB_TIMEOUT) {
+ Ok(transferred) => {
+ debug!("Wrote {} bytes to endpoint {}", transferred, endpoint);
+ Ok(())
+ }
+ Err(e) => {
+ error!("USB write error on endpoint {}: {}", endpoint, e);
+ match e {
+ rusb::Error::NoDevice => Err(UsbError::DeviceDisconnected.into()),
+ rusb::Error::Timeout => Err(UsbError::Timeout.into()),
+ _ => Err(UsbError::BulkTransferFailed(e.to_string()).into()),
+ }
+ }
+ }
+ }
+
+ /// Get device information
+ pub fn device_info(&self) -> &super::UsbDeviceInfo {
+ &self.device_info
+ }
+
+ /// Check if device is connected
+ pub fn is_connected(&self) -> bool {
+ let handle_guard = self.handle.lock().unwrap();
+ handle_guard.is_some()
+ }
+}
+
+impl Drop for UsbSupercamera {
+ fn drop(&mut self) {
+ if let Ok(handle_guard) = self.handle.try_lock() {
+ if let Some(handle) = handle_guard.as_ref() {
+ // Release interfaces
+ let _ = handle.release_interface(INTERFACE_A_NUMBER);
+ let _ = handle.release_interface(INTERFACE_B_NUMBER);
+ debug!("Released USB interfaces");
+ }
+ }
+ debug!("USB supercamera dropped");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+
+ // Mock USB context for testing - simplified for now
+ // TODO: Implement proper mock when needed for more complex testing
+
+ #[test]
+ fn test_usb_device_info() {
+ let device_info = super::super::UsbDeviceInfo::default();
+ assert_eq!(device_info.vendor_id, USB_VENDOR_ID);
+ assert_eq!(device_info.product_id, USB_PRODUCT_ID);
+ }
+
+ #[test]
+ fn test_endpoint_constants() {
+ assert_eq!(ENDPOINT_1, 1);
+ assert_eq!(ENDPOINT_2, 2);
+ assert_eq!(INTERFACE_A_NUMBER, 0);
+ assert_eq!(INTERFACE_B_NUMBER, 1);
+ assert_eq!(INTERFACE_B_ALTERNATE_SETTING, 1);
+ }
+
+ #[test]
+ fn test_usb_supercamera_creation_fails_without_device() {
+ // This test will fail in CI/CD environments without actual USB device
+ // In a real test environment, we'd use mocks
+ assert!(true);
+ }
+}
diff --git a/src/usb/mod.rs b/src/usb/mod.rs
new file mode 100644
index 0000000..38d9c7d
--- /dev/null
+++ b/src/usb/mod.rs
@@ -0,0 +1,164 @@
+//! USB communication module for the Geek szitman supercamera
+
+mod device;
+mod transfer;
+
+pub use device::UsbSupercamera;
+pub use transfer::{UsbTransferConfig, UsbTransferError, UsbTransferResult, UsbTransferStats};
+
+use rusb::{Direction, TransferType};
+use std::time::Duration;
+
+/// USB device constants
+pub const USB_VENDOR_ID: u16 = 0x2ce3;
+pub const USB_PRODUCT_ID: u16 = 0x3828;
+pub const INTERFACE_A_NUMBER: u8 = 0;
+pub const INTERFACE_B_NUMBER: u8 = 1;
+pub const INTERFACE_B_ALTERNATE_SETTING: u8 = 1;
+pub const ENDPOINT_1: u8 = 1;
+pub const ENDPOINT_2: u8 = 2;
+pub const USB_TIMEOUT: Duration = Duration::from_millis(1000);
+
+/// USB device information
+#[derive(Debug, Clone)]
+pub struct UsbDeviceInfo {
+ pub vendor_id: u16,
+ pub product_id: u16,
+ pub manufacturer: Option<String>,
+ pub product: Option<String>,
+ pub serial_number: Option<String>,
+}
+
+impl Default for UsbDeviceInfo {
+ fn default() -> Self {
+ Self {
+ vendor_id: USB_VENDOR_ID,
+ product_id: USB_PRODUCT_ID,
+ manufacturer: None,
+ product: None,
+ serial_number: None,
+ }
+ }
+}
+
+/// USB endpoint configuration
+#[derive(Debug, Clone)]
+pub struct UsbEndpoint {
+ pub address: u8,
+ pub direction: Direction,
+ pub transfer_type: TransferType,
+ pub max_packet_size: u16,
+}
+
+impl UsbEndpoint {
+ /// Create a new USB endpoint
+ pub fn new(
+ address: u8,
+ direction: Direction,
+ transfer_type: TransferType,
+ max_packet_size: u16,
+ ) -> Self {
+ Self {
+ address,
+ direction,
+ transfer_type,
+ max_packet_size,
+ }
+ }
+}
+
+/// USB interface configuration
+#[derive(Debug, Clone)]
+pub struct UsbInterface {
+ pub number: u8,
+ pub alternate_setting: u8,
+ pub endpoints: Vec<UsbEndpoint>,
+}
+
+impl UsbInterface {
+ /// Create a new USB interface
+ pub fn new(number: u8, alternate_setting: u8) -> Self {
+ Self {
+ number,
+ alternate_setting,
+ endpoints: Vec::new(),
+ }
+ }
+
+ /// Add an endpoint to this interface
+ pub fn add_endpoint(&mut self, endpoint: UsbEndpoint) {
+ self.endpoints.push(endpoint);
+ }
+}
+
+/// USB device configuration
+#[derive(Debug, Clone)]
+pub struct UsbConfig {
+ pub interfaces: Vec<UsbInterface>,
+ pub max_packet_size: u16,
+}
+
+impl Default for UsbConfig {
+ fn default() -> Self {
+ let mut interface_a = UsbInterface::new(INTERFACE_A_NUMBER, 0);
+ interface_a.add_endpoint(UsbEndpoint::new(
+ ENDPOINT_1,
+ Direction::In,
+ TransferType::Bulk,
+ 0x1000,
+ ));
+
+ let mut interface_b = UsbInterface::new(INTERFACE_B_NUMBER, INTERFACE_B_ALTERNATE_SETTING);
+ interface_b.add_endpoint(UsbEndpoint::new(
+ ENDPOINT_2,
+ Direction::Out,
+ TransferType::Bulk,
+ 64,
+ ));
+
+ Self {
+ interfaces: vec![interface_a, interface_b],
+ max_packet_size: 0x1000,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_usb_device_info_default() {
+ let info = UsbDeviceInfo::default();
+ assert_eq!(info.vendor_id, USB_VENDOR_ID);
+ assert_eq!(info.product_id, USB_PRODUCT_ID);
+ }
+
+ #[test]
+ fn test_usb_endpoint_creation() {
+ let endpoint = UsbEndpoint::new(ENDPOINT_1, Direction::In, TransferType::Bulk, 0x1000);
+ assert_eq!(endpoint.address, ENDPOINT_1);
+ assert_eq!(endpoint.direction, Direction::In);
+ assert_eq!(endpoint.transfer_type, TransferType::Bulk);
+ assert_eq!(endpoint.max_packet_size, 0x1000);
+ }
+
+ #[test]
+ fn test_usb_interface_management() {
+ let mut interface = UsbInterface::new(0, 0);
+ let endpoint = UsbEndpoint::new(1, Direction::In, TransferType::Bulk, 64);
+
+ interface.add_endpoint(endpoint);
+ assert_eq!(interface.endpoints.len(), 1);
+ assert_eq!(interface.endpoints[0].address, 1);
+ }
+
+ #[test]
+ fn test_usb_config_default() {
+ let config = UsbConfig::default();
+ assert_eq!(config.interfaces.len(), 2);
+ assert_eq!(config.interfaces[0].number, INTERFACE_A_NUMBER);
+ assert_eq!(config.interfaces[1].number, INTERFACE_B_NUMBER);
+ assert_eq!(config.max_packet_size, 0x1000);
+ }
+}
diff --git a/src/usb/transfer.rs b/src/usb/transfer.rs
new file mode 100644
index 0000000..51d31ce
--- /dev/null
+++ b/src/usb/transfer.rs
@@ -0,0 +1,287 @@
+//! USB transfer handling for the Geek szitman supercamera
+
+use crate::error::Result;
+use rusb::{Direction, TransferType};
+use std::time::Duration;
+use tracing::{error, trace};
+
+/// USB transfer error types
+#[derive(Debug, thiserror::Error)]
+pub enum UsbTransferError {
+ /// Transfer timeout
+ #[error("Transfer timeout after {:?}", duration)]
+ Timeout { duration: Duration },
+
+ /// Transfer failed
+ #[error("Transfer failed: {}", reason)]
+ Failed { reason: String },
+
+ /// Invalid endpoint
+ #[error("Invalid endpoint: {}", endpoint)]
+ InvalidEndpoint { endpoint: u8 },
+
+ /// Buffer too small
+ #[error("Buffer too small: required {}, provided {}", required, provided)]
+ BufferTooSmall { required: usize, provided: usize },
+}
+
+/// USB transfer configuration
+#[derive(Debug, Clone)]
+pub struct UsbTransferConfig {
+ pub timeout: Duration,
+ pub retry_count: u32,
+ pub buffer_size: usize,
+}
+
+impl Default for UsbTransferConfig {
+ fn default() -> Self {
+ Self {
+ timeout: Duration::from_millis(1000),
+ retry_count: 3,
+ buffer_size: 0x1000,
+ }
+ }
+}
+
+/// USB transfer result
+#[derive(Debug)]
+pub struct UsbTransferResult {
+ pub bytes_transferred: usize,
+ pub endpoint: u8,
+ pub direction: Direction,
+ pub transfer_type: TransferType,
+}
+
+impl UsbTransferResult {
+ /// Create a new transfer result
+ pub fn new(
+ bytes_transferred: usize,
+ endpoint: u8,
+ direction: Direction,
+ transfer_type: TransferType,
+ ) -> Self {
+ Self {
+ bytes_transferred,
+ endpoint,
+ direction,
+ transfer_type,
+ }
+ }
+
+ /// Check if the transfer was successful
+ pub fn is_successful(&self) -> bool {
+ self.bytes_transferred > 0
+ }
+
+ /// Get the effective endpoint address
+ pub fn endpoint_address(&self) -> u8 {
+ match self.direction {
+ Direction::In => self.endpoint | 0x80,
+ Direction::Out => self.endpoint,
+ }
+ }
+}
+
+/// USB transfer statistics
+#[derive(Debug, Default)]
+pub struct UsbTransferStats {
+ pub total_transfers: u64,
+ pub successful_transfers: u64,
+ pub failed_transfers: u64,
+ pub total_bytes_transferred: u64,
+ pub average_transfer_size: f64,
+}
+
+impl UsbTransferStats {
+ /// Update statistics with a transfer result
+ pub fn update(&mut self, result: &UsbTransferResult) {
+ self.total_transfers += 1;
+
+ if result.is_successful() {
+ self.successful_transfers += 1;
+ self.total_bytes_transferred += result.bytes_transferred as u64;
+ } else {
+ self.failed_transfers += 1;
+ }
+
+ // Update average transfer size
+ if self.successful_transfers > 0 {
+ self.average_transfer_size =
+ self.total_bytes_transferred as f64 / self.successful_transfers as f64;
+ }
+ }
+
+ /// Get success rate as a percentage
+ pub fn success_rate(&self) -> f64 {
+ if self.total_transfers == 0 {
+ 0.0
+ } else {
+ (self.successful_transfers as f64 / self.total_transfers as f64) * 100.0
+ }
+ }
+
+ /// Reset statistics
+ pub fn reset(&mut self) {
+ *self = Self::default();
+ }
+}
+
+/// USB transfer manager
+pub struct UsbTransferManager {
+ config: UsbTransferConfig,
+ stats: UsbTransferStats,
+}
+
+impl UsbTransferManager {
+ /// Create a new transfer manager
+ pub fn new(config: UsbTransferConfig) -> Self {
+ Self {
+ config,
+ stats: UsbTransferStats::default(),
+ }
+ }
+
+ /// Create a transfer manager with default configuration
+ pub fn new_default() -> Self {
+ Self::new(UsbTransferConfig::default())
+ }
+
+ /// Get current statistics
+ pub fn stats(&self) -> &UsbTransferStats {
+ &self.stats
+ }
+
+ /// Get mutable reference to statistics
+ pub fn stats_mut(&mut self) -> &mut UsbTransferStats {
+ &mut self.stats
+ }
+
+ /// Reset statistics
+ pub fn reset_stats(&mut self) {
+ self.stats.reset();
+ }
+
+ /// Validate endpoint configuration
+ pub fn validate_endpoint(
+ &self,
+ endpoint: u8,
+ direction: Direction,
+ transfer_type: TransferType,
+ ) -> Result<()> {
+ if endpoint > 15 {
+ return Err(UsbTransferError::InvalidEndpoint { endpoint }.into());
+ }
+
+ // Validate endpoint address based on direction
+ let endpoint_address = match direction {
+ Direction::In => endpoint | 0x80,
+ Direction::Out => endpoint,
+ };
+
+ trace!(
+ "Validated endpoint: 0x{:02X} ({:?}, {:?})",
+ endpoint_address,
+ direction,
+ transfer_type
+ );
+ Ok(())
+ }
+
+ /// Validate buffer size
+ pub fn validate_buffer(&self, buffer_size: usize) -> Result<()> {
+ if buffer_size < self.config.buffer_size {
+ return Err(UsbTransferError::BufferTooSmall {
+ required: self.config.buffer_size,
+ provided: buffer_size,
+ }
+ .into());
+ }
+ Ok(())
+ }
+
+ /// Get transfer configuration
+ pub fn config(&self) -> &UsbTransferConfig {
+ &self.config
+ }
+
+ /// Update transfer configuration
+ pub fn update_config(&mut self, config: UsbTransferConfig) {
+ self.config = config;
+ }
+}
+
+impl Default for UsbTransferManager {
+ fn default() -> Self {
+ Self::new_default()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_usb_transfer_config_default() {
+ let config = UsbTransferConfig::default();
+ assert_eq!(config.timeout, Duration::from_millis(1000));
+ assert_eq!(config.retry_count, 3);
+ assert_eq!(config.buffer_size, 0x1000);
+ }
+
+ #[test]
+ fn test_usb_transfer_result() {
+ let result = UsbTransferResult::new(1024, 1, Direction::In, TransferType::Bulk);
+
+ assert_eq!(result.bytes_transferred, 1024);
+ assert_eq!(result.endpoint, 1);
+ assert!(result.is_successful());
+ assert_eq!(result.endpoint_address(), 0x81);
+ }
+
+ #[test]
+ fn test_usb_transfer_stats() {
+ let mut stats = UsbTransferStats::default();
+
+ let result = UsbTransferResult::new(1024, 1, Direction::In, TransferType::Bulk);
+ stats.update(&result);
+
+ assert_eq!(stats.total_transfers, 1);
+ assert_eq!(stats.successful_transfers, 1);
+ assert_eq!(stats.failed_transfers, 0);
+ assert_eq!(stats.total_bytes_transferred, 1024);
+ assert_eq!(stats.success_rate(), 100.0);
+ }
+
+ #[test]
+ fn test_usb_transfer_manager() {
+ let manager = UsbTransferManager::new_default();
+ assert_eq!(manager.config().timeout, Duration::from_millis(1000));
+ assert_eq!(manager.stats().total_transfers, 0);
+ }
+
+ #[test]
+ fn test_endpoint_validation() {
+ let manager = UsbTransferManager::new_default();
+
+ // Valid endpoint
+ assert!(manager
+ .validate_endpoint(1, Direction::In, TransferType::Bulk)
+ .is_ok());
+
+ // Invalid endpoint
+ assert!(manager
+ .validate_endpoint(16, Direction::In, TransferType::Bulk)
+ .is_err());
+ }
+
+ #[test]
+ fn test_buffer_validation() {
+ let manager = UsbTransferManager::new_default();
+
+ // Valid buffer size
+ assert!(manager.validate_buffer(0x1000).is_ok());
+
+ // Invalid buffer size
+ assert!(manager.validate_buffer(0x800).is_err());
+ }
+}
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
new file mode 100644
index 0000000..b89357e
--- /dev/null
+++ b/src/utils/mod.rs
@@ -0,0 +1,350 @@
+//! Utility functions and types for the geek-szitman-supercamera crate
+
+use std::time::{Duration, Instant};
+use tracing::info;
+
+/// Signal handler for graceful shutdown
+pub struct SignalHandler {
+ shutdown_requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
+}
+
+impl SignalHandler {
+ /// Create a new signal handler
+ pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
+ let shutdown_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let shutdown_clone = shutdown_requested.clone();
+
+ ctrlc::set_handler(move || {
+ info!("Received shutdown signal");
+ shutdown_clone.store(true, std::sync::atomic::Ordering::SeqCst);
+ })?;
+
+ Ok(Self { shutdown_requested })
+ }
+
+ /// Check if shutdown was requested
+ pub fn shutdown_requested(&self) -> bool {
+ self.shutdown_requested
+ .load(std::sync::atomic::Ordering::SeqCst)
+ }
+
+ /// Wait for shutdown signal
+ pub fn wait_for_shutdown(&self) {
+ while !self.shutdown_requested() {
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ }
+
+ /// Request shutdown programmatically
+ pub fn request_shutdown(&self) {
+ self.shutdown_requested
+ .store(true, std::sync::atomic::Ordering::SeqCst);
+ }
+
+ /// Wait for shutdown signal with timeout. Returns true if shutdown was requested, false if timed out.
+ pub fn wait_for_shutdown_with_timeout(&self, timeout: Duration) -> bool {
+ let start = Instant::now();
+ while !self.shutdown_requested() {
+ if start.elapsed() >= timeout {
+ return false; // timed out
+ }
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ true
+ }
+}
+
+/// Performance metrics tracker
+pub struct PerformanceTracker {
+ start_time: Instant,
+ frame_count: u64,
+ total_bytes: u64,
+ last_fps_update: Instant,
+ current_fps: f64,
+}
+
+impl PerformanceTracker {
+ /// Create a new performance tracker
+ pub fn new() -> Self {
+ Self {
+ start_time: Instant::now(),
+ frame_count: 0,
+ total_bytes: 0,
+ last_fps_update: Instant::now(),
+ current_fps: 0.0,
+ }
+ }
+
+ /// Record a frame
+ pub fn record_frame(&mut self, bytes: usize) {
+ self.frame_count += 1;
+ self.total_bytes += bytes as u64;
+
+ // Update FPS every second
+ let now = Instant::now();
+ if now.duration_since(self.last_fps_update) >= Duration::from_secs(1) {
+ let elapsed = now.duration_since(self.last_fps_update).as_secs_f64();
+ self.current_fps = 1.0 / elapsed;
+ self.last_fps_update = now;
+ }
+ }
+
+ /// Get current FPS
+ pub fn current_fps(&self) -> f64 {
+ self.current_fps
+ }
+
+ /// Get total frame count
+ pub fn total_frames(&self) -> u64 {
+ self.frame_count
+ }
+
+ /// Get total bytes processed
+ pub fn total_bytes(&self) -> u64 {
+ self.total_bytes
+ }
+
+ /// Get average frame size
+ pub fn average_frame_size(&self) -> f64 {
+ if self.frame_count > 0 {
+ self.total_bytes as f64 / self.frame_count as f64
+ } else {
+ 0.0
+ }
+ }
+
+ /// Get uptime
+ pub fn uptime(&self) -> Duration {
+ self.start_time.elapsed()
+ }
+
+ /// Get performance summary
+ pub fn summary(&self) -> PerformanceSummary {
+ PerformanceSummary {
+ uptime: self.uptime(),
+ total_frames: self.total_frames(),
+ total_bytes: self.total_bytes(),
+ current_fps: self.current_fps(),
+ average_frame_size: self.average_frame_size(),
+ }
+ }
+}
+
+impl Default for PerformanceTracker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Performance summary
+#[derive(Debug, Clone)]
+pub struct PerformanceSummary {
+ pub uptime: Duration,
+ pub total_frames: u64,
+ pub total_bytes: u64,
+ pub current_fps: f64,
+ pub average_frame_size: f64,
+}
+
+impl PerformanceSummary {
+ /// Format uptime as human-readable string
+ pub fn uptime_formatted(&self) -> String {
+ let secs = self.uptime.as_secs();
+ let hours = secs / 3600;
+ let minutes = (secs % 3600) / 60;
+ let seconds = secs % 60;
+
+ if hours > 0 {
+ format!("{hours}h {minutes}m {seconds}s")
+ } else if minutes > 0 {
+ format!("{minutes}m {seconds}s")
+ } else {
+ format!("{seconds}s")
+ }
+ }
+
+ /// Format bytes as human-readable string
+ pub fn bytes_formatted(&self) -> String {
+ const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
+ let mut size = self.total_bytes as f64;
+ let mut unit_index = 0;
+
+ while size >= 1024.0 && unit_index < UNITS.len() - 1 {
+ size /= 1024.0;
+ unit_index += 1;
+ }
+
+ format!("{:.1} {}", size, UNITS[unit_index])
+ }
+}
+
+/// Configuration file loader
+pub struct ConfigLoader;
+
+impl ConfigLoader {
+ /// Load configuration from file
+ pub fn load_from_file<T>(path: &str) -> Result<T, Box<dyn std::error::Error>>
+ where
+ T: serde::de::DeserializeOwned,
+ {
+ let content = std::fs::read_to_string(path)?;
+ let config: T = serde_json::from_str(&content)?;
+ Ok(config)
+ }
+
+ /// Save configuration to file
+ pub fn save_to_file<T>(path: &str, config: &T) -> Result<(), Box<dyn std::error::Error>>
+ where
+ T: serde::Serialize,
+ {
+ let content = serde_json::to_string_pretty(config)?;
+ std::fs::write(path, content)?;
+ Ok(())
+ }
+
+ /// Load configuration with fallback to defaults
+ pub fn load_with_defaults<T>(path: &str) -> T
+ where
+ T: serde::de::DeserializeOwned + Default,
+ {
+ Self::load_from_file(path).unwrap_or_default()
+ }
+}
+
+/// File utilities
+pub struct FileUtils;
+
+impl FileUtils {
+ /// Ensure directory exists
+ pub fn ensure_dir(path: &str) -> Result<(), Box<dyn std::error::Error>> {
+ std::fs::create_dir_all(path)?;
+ Ok(())
+ }
+
+ /// Get file size
+ pub fn get_file_size(path: &str) -> Result<u64, Box<dyn std::error::Error>> {
+ let metadata = std::fs::metadata(path)?;
+ Ok(metadata.len())
+ }
+
+ /// Check if file exists
+ pub fn file_exists(path: &str) -> bool {
+ std::path::Path::new(path).exists()
+ }
+
+ /// Get file extension
+ pub fn get_extension(path: &str) -> Option<String> {
+ std::path::Path::new(path)
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .map(|s| s.to_string())
+ }
+}
+
+/// Time utilities
+pub struct TimeUtils;
+
+impl TimeUtils {
+ /// Format duration as human-readable string
+ pub fn format_duration(duration: Duration) -> String {
+ let secs = duration.as_secs();
+ let millis = duration.subsec_millis();
+
+ if secs > 0 {
+ format!("{secs}.{millis:03}s")
+ } else {
+ format!("{millis}ms")
+ }
+ }
+
+ /// Sleep with progress callback
+ pub fn sleep_with_progress<F>(duration: Duration, mut progress_callback: F)
+ where
+ F: FnMut(f64),
+ {
+ let start = Instant::now();
+ let total_duration = duration.as_millis() as f64;
+
+ while start.elapsed() < duration {
+ let elapsed = start.elapsed().as_millis() as f64;
+ let progress = (elapsed / total_duration).min(1.0);
+ progress_callback(progress);
+
+ std::thread::sleep(Duration::from_millis(10));
+ }
+
+ progress_callback(1.0);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_performance_tracker() {
+ let mut tracker = PerformanceTracker::new();
+
+ // Record some frames
+ tracker.record_frame(1024);
+ tracker.record_frame(2048);
+ tracker.record_frame(1536);
+
+ assert_eq!(tracker.total_frames(), 3);
+ assert_eq!(tracker.total_bytes(), 4608);
+ assert_eq!(tracker.average_frame_size(), 1536.0);
+ }
+
+ #[test]
+ fn test_performance_summary() {
+ let mut tracker = PerformanceTracker::new();
+ tracker.record_frame(1024);
+
+ let summary = tracker.summary();
+ assert_eq!(summary.total_frames, 1);
+ assert_eq!(summary.total_bytes, 1024);
+ assert_eq!(summary.average_frame_size, 1024.0);
+
+ // Test formatting
+ assert!(summary.uptime_formatted().contains("s"));
+ assert_eq!(summary.bytes_formatted(), "1.0 KB");
+ }
+
+ #[test]
+ fn test_file_utils() {
+ // Test file existence check
+ assert!(FileUtils::file_exists("src/utils/mod.rs"));
+ assert!(!FileUtils::file_exists("nonexistent_file.txt"));
+
+ // Test extension extraction
+ assert_eq!(
+ FileUtils::get_extension("test.txt"),
+ Some("txt".to_string())
+ );
+ assert_eq!(FileUtils::get_extension("no_extension"), None);
+ }
+
+ #[test]
+ fn test_time_utils() {
+ let duration = Duration::from_millis(1500);
+ let formatted = TimeUtils::format_duration(duration);
+ assert!(formatted.contains("1.500s"));
+
+ let short_duration = Duration::from_millis(500);
+ let formatted = TimeUtils::format_duration(short_duration);
+ assert!(formatted.contains("500ms"));
+ }
+
+ #[test]
+ fn test_sleep_with_progress() {
+ let mut progress_values = Vec::new();
+ let duration = Duration::from_millis(100);
+
+ TimeUtils::sleep_with_progress(duration, |progress| {
+ progress_values.push(progress);
+ });
+
+ assert!(!progress_values.is_empty());
+ assert!(progress_values.last().unwrap() >= &1.0);
+ }
+}
diff --git a/src/video/mod.rs b/src/video/mod.rs
new file mode 100644
index 0000000..4fbab4f
--- /dev/null
+++ b/src/video/mod.rs
@@ -0,0 +1,330 @@
+//! Video backend module for the Geek szitman supercamera
+
+mod pipewire;
+mod v4l2;
+mod stdout;
+
+pub use pipewire::{PipeWireBackend, PipeWireConfig};
+pub use v4l2::V4L2Backend;
+pub use stdout::{StdoutBackend, StdoutConfig, HeaderFormat};
+
+use crate::error::{Result, VideoError};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use std::sync::Mutex;
+use tracing::{info};
+
+/// Video backend trait for different video output methods
+pub trait VideoBackendTrait: Send + Sync {
+ /// Initialize the video backend
+ fn initialize(&mut self) -> Result<()>;
+
+ /// Push a frame to the video backend
+ fn push_frame(&self, frame_data: &[u8]) -> Result<()>;
+
+ /// Get backend statistics
+ fn get_stats(&self) -> VideoStats;
+
+ /// Check if backend is ready
+ fn is_ready(&self) -> bool;
+
+ /// Shutdown the backend
+ fn shutdown(&mut self) -> Result<()>;
+}
+
+/// Video backend types
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub enum VideoBackendType {
+ /// PipeWire backend
+ PipeWire,
+ /// V4L2 backend (for future use)
+ V4L2,
+ /// Stdout backend for piping to other tools
+ Stdout,
+}
+
+/// Video backend configuration
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct VideoConfig {
+ pub backend_type: VideoBackendType,
+ pub width: u32,
+ pub height: u32,
+ pub fps: u32,
+ pub format: VideoFormat,
+ pub device_path: Option<String>,
+}
+
+impl Default for VideoConfig {
+ fn default() -> Self {
+ Self {
+ backend_type: VideoBackendType::PipeWire,
+ width: 640,
+ height: 480,
+ fps: 30,
+ format: VideoFormat::MJPEG,
+ device_path: None,
+ }
+ }
+}
+
+/// Video format types
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum VideoFormat {
+ /// Motion JPEG
+ MJPEG,
+ /// YUV420
+ YUV420,
+ /// RGB24
+ RGB24,
+}
+
+impl VideoFormat {
+ /// Get the format name as a string
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ VideoFormat::MJPEG => "MJPEG",
+ VideoFormat::YUV420 => "YUV420",
+ VideoFormat::RGB24 => "RGB24",
+ }
+ }
+
+ /// Get the bytes per pixel
+ pub fn bytes_per_pixel(&self) -> usize {
+ match self {
+ VideoFormat::MJPEG => 0, // Variable for MJPEG
+ VideoFormat::YUV420 => 1,
+ VideoFormat::RGB24 => 3,
+ }
+ }
+}
+
+/// Video statistics
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct VideoStats {
+ pub frames_pushed: u64,
+ pub frames_dropped: u64,
+ pub total_bytes: u64,
+ pub fps: f64,
+ pub backend_type: VideoBackendType,
+ pub is_ready: bool,
+}
+
+impl Default for VideoStats {
+ fn default() -> Self {
+ Self {
+ frames_pushed: 0,
+ frames_dropped: 0,
+ total_bytes: 0,
+ fps: 0.0,
+ backend_type: VideoBackendType::PipeWire,
+ is_ready: false,
+ }
+ }
+}
+
+/// Video backend factory
+pub struct VideoBackend;
+
+impl VideoBackend {
+ /// Create a new PipeWire backend
+ pub fn new_pipewire() -> Result<Box<dyn VideoBackendTrait>> {
+ Ok(Box::new(PipeWireBackend::new(PipeWireConfig::default())))
+ }
+
+ /// Create a new V4L2 backend (for future use)
+ pub fn new_v4l2() -> Result<Box<dyn VideoBackendTrait>> {
+ Ok(Box::new(V4L2Backend::new()?))
+ }
+
+ /// Create a new stdout backend
+ pub fn new_stdout() -> Result<Box<dyn VideoBackendTrait>> {
+ Ok(Box::new(StdoutBackend::new()))
+ }
+
+ /// Create a backend based on configuration
+ pub fn from_config(config: &VideoConfig) -> Result<Box<dyn VideoBackendTrait>> {
+ match config.backend_type {
+ VideoBackendType::PipeWire => Self::new_pipewire(),
+ VideoBackendType::V4L2 => Self::new_v4l2(),
+ VideoBackendType::Stdout => Self::new_stdout(),
+ }
+ }
+
+ /// Create a backend from type
+ pub fn from_type(backend_type: VideoBackendType) -> Result<Box<dyn VideoBackendTrait>> {
+ match backend_type {
+ VideoBackendType::PipeWire => Self::new_pipewire(),
+ VideoBackendType::V4L2 => Self::new_v4l2(),
+ VideoBackendType::Stdout => Self::new_stdout(),
+ }
+ }
+}
+
+/// Video frame metadata
+#[derive(Debug, Clone)]
+pub struct VideoFrame {
+ pub data: Vec<u8>,
+ pub width: u32,
+ pub height: u32,
+ pub format: VideoFormat,
+ pub timestamp: std::time::Instant,
+}
+
+impl VideoFrame {
+ /// Create a new video frame
+ pub fn new(data: Vec<u8>, width: u32, height: u32, format: VideoFormat) -> Self {
+ Self {
+ data,
+ width,
+ height,
+ format,
+ timestamp: std::time::Instant::now(),
+ }
+ }
+
+ /// Get frame size in bytes
+ pub fn size(&self) -> usize {
+ self.data.len()
+ }
+
+ /// Get frame dimensions
+ pub fn dimensions(&self) -> (u32, u32) {
+ (self.width, self.height)
+ }
+
+ /// Check if frame is valid
+ pub fn is_valid(&self) -> bool {
+ !self.data.is_empty() && self.width > 0 && self.height > 0
+ }
+}
+
+/// Video backend manager
+pub struct VideoBackendManager {
+ backend: Arc<Mutex<Box<dyn VideoBackendTrait>>>,
+ config: VideoConfig,
+ stats: Arc<Mutex<VideoStats>>,
+}
+
+impl VideoBackendManager {
+ /// Create a new video backend manager
+ pub fn new(config: VideoConfig) -> Result<Self> {
+ let backend = VideoBackend::from_config(&config)?;
+ let stats = Arc::new(Mutex::new(VideoStats::default()));
+
+ let manager = Self {
+ backend: Arc::new(Mutex::new(backend)),
+ config,
+ stats,
+ };
+
+ // Initialize the backend
+ let mut backend_guard = manager.backend.lock().unwrap();
+ backend_guard.initialize()?;
+ drop(backend_guard);
+
+ Ok(manager)
+ }
+
+ /// Push a frame to the video backend
+ pub fn push_frame(&self, frame_data: &[u8]) -> Result<()> {
+ let backend = self.backend.lock().unwrap();
+
+ if !backend.is_ready() {
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ // Update statistics
+ let mut stats = self.stats.lock().unwrap();
+ stats.frames_pushed += 1;
+ stats.total_bytes += frame_data.len() as u64;
+ drop(stats);
+
+ // Push frame to backend
+ backend.push_frame(frame_data)?;
+
+ Ok(())
+ }
+
+ /// Get current statistics
+ pub fn get_stats(&self) -> VideoStats {
+ let stats = self.stats.lock().unwrap();
+ stats.clone()
+ }
+
+ /// Switch video backend
+ pub fn switch_backend(&mut self, new_type: VideoBackendType) -> Result<()> {
+ // Shutdown current backend
+ let mut backend = self.backend.lock().unwrap();
+ backend.shutdown()?;
+ drop(backend);
+
+ // Create new backend
+ let new_backend = VideoBackend::from_type(new_type)?;
+ let mut backend = self.backend.lock().unwrap();
+ *backend = new_backend;
+
+ // Initialize new backend
+ backend.initialize()?;
+
+ // Update config
+ self.config.backend_type = new_type;
+
+ info!("Switched to {:?} backend", new_type);
+ Ok(())
+ }
+
+ /// Get configuration
+ pub fn config(&self) -> &VideoConfig {
+ &self.config
+ }
+
+ /// Update configuration
+ pub fn update_config(&mut self, config: VideoConfig) -> Result<()> {
+ // Recreate backend if type changed
+ if self.config.backend_type != config.backend_type {
+ self.switch_backend(config.backend_type)?;
+ }
+
+ self.config = config;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_video_backend_factory() {
+ // Test PipeWire backend creation
+ let pipewire_backend = VideoBackend::new_pipewire();
+ assert!(pipewire_backend.is_ok());
+
+ // Test V4L2 backend creation
+ let v4l2_backend = VideoBackend::new_v4l2();
+ assert!(v4l2_backend.is_ok());
+ }
+
+ #[test]
+ fn test_video_frame_creation() {
+ let frame_data = vec![0u8; 1024];
+ let frame = VideoFrame::new(frame_data.clone(), 32, 32, VideoFormat::RGB24);
+
+ assert_eq!(frame.data, frame_data);
+ assert_eq!(frame.width, 32);
+ assert_eq!(frame.height, 32);
+ assert_eq!(frame.format, VideoFormat::RGB24);
+ assert!(frame.is_valid());
+ }
+
+ #[test]
+ fn test_video_format_conversions() {
+ assert_eq!(VideoFormat::MJPEG.as_str(), "MJPEG");
+ assert_eq!(VideoFormat::YUV420.as_str(), "YUV420");
+ assert_eq!(VideoFormat::RGB24.as_str(), "RGB24");
+
+ assert_eq!(VideoFormat::MJPEG.bytes_per_pixel(), 0);
+ assert_eq!(VideoFormat::YUV420.bytes_per_pixel(), 1);
+ assert_eq!(VideoFormat::RGB24.bytes_per_pixel(), 3);
+ }
+}
diff --git a/src/video/pipewire.rs b/src/video/pipewire.rs
new file mode 100644
index 0000000..62dad11
--- /dev/null
+++ b/src/video/pipewire.rs
@@ -0,0 +1,773 @@
+//! PipeWire backend for video streaming using native library
+
+use super::{VideoBackendTrait, VideoFormat, VideoStats};
+use crate::error::{Result, VideoError};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU32, Ordering, Ordering as AtomicOrdering};
+use std::sync::Mutex;
+use std::time::Instant;
+use tracing::{debug, info, trace, error, warn};
+use std::thread;
+use std::sync::mpsc::{self, Sender, Receiver};
+
+// PipeWire imports
+use pipewire::{
+ main_loop::MainLoop,
+ context::Context,
+ stream::{Stream, StreamFlags, StreamState},
+ properties::properties,
+ keys,
+ spa::pod::{self, Pod, Object},
+ spa::utils::{Direction, SpaTypes, Fraction, Rectangle},
+ spa::param::ParamType,
+ spa::param::format::{FormatProperties, MediaSubtype, MediaType},
+ spa::pod::serialize::PodSerializer,
+};
+
+/// PipeWire backend implementation using native library
+pub struct PipeWireBackend {
+ is_initialized: bool,
+ stats: Arc<Mutex<VideoStats>>,
+ config: PipeWireConfig,
+ running: Arc<AtomicBool>,
+ virtual_node_id: Option<u32>,
+ pw_frame_sender: Option<Sender<Vec<u8>>>, // Separate sender for PipeWire thread
+ stats_frame_sender: Option<Sender<Vec<u8>>>, // Separate sender for stats thread
+ last_frame_time: Arc<Mutex<Instant>>,
+
+ // PipeWire objects - these need to be in a separate thread-safe context
+ pw_thread: Option<thread::JoinHandle<()>>,
+}
+
+/// PipeWire configuration
+#[derive(Debug, Clone)]
+pub struct PipeWireConfig {
+ pub node_name: String,
+ pub description: String,
+ pub media_class: String,
+ pub format: VideoFormat,
+ pub width: u32,
+ pub height: u32,
+ pub framerate: u32,
+}
+
+impl Default for PipeWireConfig {
+ fn default() -> Self {
+ Self {
+ node_name: "geek-szitman-supercamera".to_string(),
+ description: "Geek Szitman SuperCamera - High-quality virtual camera for streaming and recording".to_string(),
+ media_class: "Video/Source".to_string(),
+ format: VideoFormat::MJPEG, // Changed back to MJPEG since that's what the camera provides
+ width: 640,
+ height: 480,
+ framerate: 30,
+ }
+ }
+}
+
+impl PipeWireBackend {
+ pub fn new(config: PipeWireConfig) -> Self {
+ Self {
+ is_initialized: false,
+ stats: Arc::new(Mutex::new(VideoStats::default())),
+ config,
+ running: Arc::new(AtomicBool::new(false)),
+ virtual_node_id: None,
+ pw_frame_sender: None,
+ stats_frame_sender: None,
+ last_frame_time: Arc::new(Mutex::new(Instant::now())),
+ pw_thread: None,
+ }
+ }
+
+ /// Check if PipeWire is available and running
+ fn check_pipewire_available(&self) -> Result<()> {
+ info!("Checking PipeWire availability...");
+ // This is a basic check - in a real implementation you might want to
+ // try to connect to the daemon to verify it's actually running
+ info!("PipeWire availability check passed");
+ Ok(())
+ }
+
+ /// Create a virtual camera node using native PipeWire API
+ fn create_virtual_camera_node(&mut self) -> Result<()> {
+ info!("Creating PipeWire virtual camera node using native API...");
+ info!("Node name: '{}'", self.config.node_name);
+ info!("Node description: '{}'", self.config.description);
+
+ // Start PipeWire processing in a separate thread to avoid Send/Sync issues
+ // The actual node creation and availability will be logged in the PipeWire thread
+ // Ensure the processing loop runs
+ self.running.store(true, Ordering::Relaxed);
+ let running = Arc::clone(&self.running);
+ let config = self.config.clone();
+
+ // Create channel for frame communication with PipeWire thread
+ let (frame_sender, frame_receiver) = mpsc::channel();
+ self.pw_frame_sender = Some(frame_sender);
+
+ info!("Starting PipeWire thread...");
+ let handle = thread::spawn(move || {
+ info!("PipeWire thread started, entering main loop...");
+ // Set panic hook to catch any panics in this thread
+ std::panic::set_hook(Box::new(|panic_info| {
+ error!("PipeWire thread panicked: {:?}", panic_info);
+ }));
+
+ Self::pipewire_main_loop(running, config, frame_receiver);
+ info!("PipeWire thread exiting...");
+ });
+
+ self.pw_thread = Some(handle);
+ self.virtual_node_id = Some(999); // Placeholder - will be updated when stream is ready
+ info!("Virtual camera node creation initiated in separate thread");
+ Ok(())
+ }
+
+ /// Main PipeWire loop that runs in a separate thread
+ fn pipewire_main_loop(running: Arc<AtomicBool>, config: PipeWireConfig, frame_receiver: Receiver<Vec<u8>>) {
+ info!("Starting PipeWire main loop in thread");
+
+ info!("Initializing PipeWire...");
+ pipewire::init();
+ info!("PipeWire initialized successfully");
+
+ // Create main loop with no properties
+ info!("Creating PipeWire main loop...");
+ let mainloop = match MainLoop::new(None) {
+ Ok(ml) => {
+ info!("Main loop created successfully");
+ ml
+ },
+ Err(e) => {
+ error!("Failed to create PipeWire main loop: {}", e);
+ error!("MainLoop::new error details: {:?}", e);
+ return;
+ }
+ };
+
+ // Create context
+ info!("Creating PipeWire context...");
+ let context = match Context::new(&mainloop) {
+ Ok(ctx) => {
+ info!("Context created successfully");
+ ctx
+ },
+ Err(e) => {
+ error!("Failed to create PipeWire context: {}", e);
+ return;
+ }
+ };
+
+ // Connect to PipeWire daemon
+ info!("Connecting to PipeWire daemon...");
+ let core = match context.connect(None) {
+ Ok(c) => {
+ info!("Connected to PipeWire daemon successfully");
+ c
+ },
+ Err(e) => {
+ error!("Failed to connect to PipeWire daemon: {}", e);
+ return;
+ }
+ };
+
+ info!("PipeWire connection established successfully");
+
+ // Set up registry listener to capture object.serial when our node appears
+ let serial_slot = Arc::new(AtomicU32::new(0));
+ let serial_slot_clone = Arc::clone(&serial_slot);
+ let wanted_name = config.node_name.clone();
+
+ let registry = core.get_registry().expect("get_registry");
+ let _reg_listener = registry
+ .add_listener_local()
+ .global(move |global_obj| {
+ if global_obj.type_ == pipewire::types::ObjectType::Node {
+ if let Some(props) = &global_obj.props {
+ if let Some(name) = props.get("node.name") {
+ if name == wanted_name {
+ if let Some(s) = props.get("object.serial") {
+ if let Ok(v) = s.parse::<u32>() {
+ serial_slot_clone.store(v, AtomicOrdering::SeqCst);
+ info!("Discovered our node in registry: node.name={} object.serial={}", name, v);
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+ .register();
+
+ // User data for stream callbacks
+ #[derive(Debug)]
+ struct UserData {
+ is_mjpeg: bool,
+ frame_size: u32,
+ stride: i32,
+ current_frame: Arc<Mutex<Option<Vec<u8>>>>,
+ }
+
+ let current_frame = Arc::new(Mutex::new(None));
+ let current_frame_clone = Arc::clone(&current_frame);
+
+ // Start frame receiver thread
+ let frame_receiver = Arc::new(Mutex::new(frame_receiver));
+ let frame_receiver_clone = Arc::clone(&frame_receiver);
+ let running_clone = Arc::clone(&running);
+ let _frame_thread = thread::spawn(move || {
+ while running_clone.load(Ordering::Relaxed) {
+ let frame_data = {
+ let receiver_guard = frame_receiver_clone.lock().unwrap();
+ match receiver_guard.recv_timeout(std::time::Duration::from_millis(16)) {
+ Ok(data) => Some(data),
+ Err(mpsc::RecvTimeoutError::Timeout) => None,
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ };
+
+ if let Some(frame_data) = frame_data {
+ let mut frame_guard = current_frame_clone.lock().unwrap();
+ *frame_guard = Some(frame_data);
+ trace!("Received new frame for PipeWire processing");
+ }
+ }
+ });
+
+ // Create a stream that will act as a video source
+ let stream = match Stream::new(
+ &core,
+ &config.node_name,
+ properties! {
+ // Essential keys for Video/Source classification
+ *keys::MEDIA_CLASS => "Video/Source",
+ *keys::NODE_NAME => config.node_name.as_str(),
+ *keys::APP_NAME => "geek-szitman-supercamera",
+ *keys::NODE_DESCRIPTION => config.description.as_str(),
+ // Additional metadata
+ "media.role" => "Camera",
+ "media.category" => "Capture",
+ // Optional cosmetics
+ "media.nick" => "SuperCamera",
+ "device.icon_name" => "camera-web",
+ // Prevent PipeWire from trying to drive the graph until someone connects
+ "node.passive" => "true",
+ },
+ ) {
+ Ok(s) => s,
+ Err(e) => {
+ error!("Failed to create PipeWire stream: {}", e);
+ return;
+ }
+ };
+
+ // Build EnumFormat pod(s) - simplified to just MJPEG
+ let width_u = config.width as u32;
+ let height_u = config.height as u32;
+ let fps_u = config.framerate as u32;
+
+ // MJPEG: JPEG compressed - simplified format
+ let enum_mjpeg = pod::object!(
+ SpaTypes::ObjectParamFormat,
+ ParamType::EnumFormat,
+ pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
+ pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Mjpg),
+ pod::property!(
+ FormatProperties::VideoSize,
+ Choice, Range, Rectangle,
+ Rectangle { width: width_u, height: height_u },
+ Rectangle { width: 16, height: 16 },
+ Rectangle { width: 4096, height: 4096 }
+ ),
+ pod::property!(
+ FormatProperties::VideoFramerate,
+ Choice, Range, Fraction,
+ Fraction { num: fps_u, denom: 1 },
+ Fraction { num: 1, denom: 1 },
+ Fraction { num: 120, denom: 1 }
+ ),
+ );
+
+ // Clone config values for closures
+ let config_width = config.width;
+ let config_height = config.height;
+ let config_framerate = config.framerate;
+
+ // Set up stream callbacks
+ let _listener = match stream
+ .add_local_listener_with_user_data(UserData {
+ is_mjpeg: false,
+ frame_size: 4 * 1024 * 1024, // safe cap
+ stride: 0,
+ current_frame: Arc::clone(&current_frame),
+ })
+ .state_changed(move |stream, _user_data, old, new| {
+ info!("PipeWire stream state: {:?} -> {:?}", old, new);
+ if matches!(new, StreamState::Paused | StreamState::Streaming) {
+ info!("PipeWire node is ready and can be targeted by applications");
+ }
+ if new == StreamState::Paused {
+ if let Err(e) = stream.set_active(true) {
+ error!("Failed to activate PipeWire stream: {}", e);
+ } else {
+ info!("Activated stream scheduling");
+ }
+ }
+ if new == StreamState::Streaming {
+ info!("Stream is now streaming - virtual camera is active!");
+ }
+ })
+ .param_changed(move |stream, user_data, id, param| {
+ if let Some(param) = param {
+ info!("Param changed: id={:?}, type={:?}, raw_id={}", id, param.type_(), id);
+
+ // Handle format negotiation - simplified approach
+ if id == ParamType::Format.as_raw() || id == ParamType::EnumFormat.as_raw() || id == 15 {
+ info!("Format param received (id={}), setting up basic MJPEG format...", id);
+
+ // Set basic MJPEG parameters
+ user_data.is_mjpeg = true;
+ user_data.frame_size = 4 * 1024 * 1024; // 4MB safe cap for MJPEG
+ user_data.stride = 0; // MJPEG doesn't have stride
+ info!("Basic MJPEG format configured: {}x{} @ {} fps", config_width, config_height, config_framerate);
+
+ // Try to activate the stream directly
+ if let Err(e) = stream.set_active(true) {
+ error!("Failed to activate stream: {}", e);
+ } else {
+ info!("Stream activated successfully");
+ }
+ } else {
+ trace!("Stream param changed: id={} (ignored)", id);
+ }
+ } else {
+ trace!("Stream param changed: id={} (ignored)", id);
+ }
+ })
+ .process(move |stream, user_data| {
+ // Dequeue buffer
+ let Some(mut buffer) = stream.dequeue_buffer() else {
+ trace!("Out of buffers");
+ return;
+ };
+
+ // Get the current frame from UPP protocol
+ let frame_data = {
+ let frame_guard = user_data.current_frame.lock().unwrap();
+ frame_guard.clone()
+ };
+
+ if let Some(frame_data) = frame_data {
+ // Process actual camera frame data from UPP protocol
+ trace!("Processing UPP camera frame: {} bytes", frame_data.len());
+
+ for data in buffer.datas_mut() {
+ if let Some(mem) = data.data() {
+ let len = mem.len();
+
+ if !user_data.is_mjpeg {
+ // Handle raw formats (RGBx or I420)
+ let w = config_width as usize;
+ let h = config_height as usize;
+ let stride = user_data.stride as usize;
+
+ if frame_data.len() >= w * h * 3 {
+ // Convert RGB to RGBA
+ let mut off = 0usize;
+ for y in 0..h {
+ let row_end = (off + stride).min(len);
+ let row = &mut mem[off..row_end];
+
+ for x in 0..w.min(row.len()/4) {
+ let src_idx = (y * w + x) * 3;
+ if src_idx + 2 < frame_data.len() {
+ row[x * 4 + 0] = frame_data[src_idx + 0]; // R
+ row[x * 4 + 1] = frame_data[src_idx + 1]; // G
+ row[x * 4 + 2] = frame_data[src_idx + 2]; // B
+ row[x * 4 + 3] = 255; // A
+ } else {
+ row[x * 4 + 0] = 0; // R
+ row[x * 4 + 1] = 0; // G
+ row[x * 4 + 2] = 0; // B
+ row[x * 4 + 3] = 255; // A
+ }
+ }
+
+ off = off.saturating_add(stride);
+ if off >= len { break; }
+ }
+
+ *data.chunk_mut().size_mut() = (w * h * 4) as u32;
+ *data.chunk_mut().stride_mut() = user_data.stride;
+ } else {
+ // Frame data too small, fill with black
+ for i in 0..len {
+ mem[i] = 0;
+ }
+ *data.chunk_mut().size_mut() = len as u32;
+ *data.chunk_mut().stride_mut() = user_data.stride;
+ }
+ } else {
+ // Handle MJPEG format - copy JPEG data directly
+ if frame_data.len() <= len {
+ mem[..frame_data.len()].copy_from_slice(&frame_data);
+ *data.chunk_mut().size_mut() = frame_data.len() as u32;
+ trace!("Copied MJPEG frame: {} bytes", frame_data.len());
+ } else {
+ // Frame too large for buffer, truncate
+ mem[..len].copy_from_slice(&frame_data[..len]);
+ *data.chunk_mut().size_mut() = len as u32;
+ warn!("MJPEG frame truncated: {} -> {} bytes", frame_data.len(), len);
+ }
+ }
+ }
+ }
+ } else {
+ // No frame data available, generate black frame as fallback
+ trace!("No UPP frame data available, generating black frame");
+
+ for data in buffer.datas_mut() {
+ if let Some(mem) = data.data() {
+ let len = mem.len();
+
+ if !user_data.is_mjpeg {
+ // Fill with black for raw formats
+ for i in 0..len {
+ mem[i] = 0;
+ }
+
+ let w = config_width as usize;
+ let h = config_height as usize;
+ *data.chunk_mut().size_mut() = (w * h * 4) as u32;
+ *data.chunk_mut().stride_mut() = user_data.stride;
+ } else {
+ // Generate minimal valid 1x1 black JPEG for MJPEG format
+ // This is a minimal valid JPEG that represents a 1x1 black pixel
+ let minimal_jpeg: [u8; 143] = [
+ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
+ 0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
+ 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
+ 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
+ 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
+ 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
+ 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
+ 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x01,
+ 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01,
+ 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xFF, 0xDA,
+ 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x37, 0xFF, 0xD9
+ ];
+
+ let copy_len = minimal_jpeg.len().min(len);
+ mem[..copy_len].copy_from_slice(&minimal_jpeg[..copy_len]);
+ *data.chunk_mut().size_mut() = copy_len as u32;
+ trace!("Generated minimal 1x1 black JPEG placeholder: {} bytes, chunk size set to {}", copy_len, copy_len);
+ }
+ }
+ }
+ }
+
+ // Debug: Log chunk sizes before queuing
+ for (i, data) in buffer.datas_mut().iter_mut().enumerate() {
+ let chunk = data.chunk_mut();
+ trace!("Buffer {} chunk {}: size={}, stride={}", i, i, chunk.size(), chunk.stride());
+ }
+
+ // Return buffer to stream by dropping it (this automatically queues it)
+ // The Buffer struct implements Drop which handles the queuing
+ drop(buffer);
+ })
+ .register()
+ {
+ Ok(l) => l,
+ Err(e) => {
+ error!("Failed to register stream listener: {}", e);
+ return;
+ }
+ };
+
+ // Connect as an output (we are a source). Use MAP_BUFFERS only, not DRIVER.
+ // Serialize EnumFormat pods to bytes and build &Pod slice
+ let obj_to_pod = |obj: Object| -> Vec<u8> {
+ let value = pod::Value::Object(obj);
+ PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &value)
+ .unwrap()
+ .0
+ .into_inner()
+ };
+ let enum_bytes: Vec<Vec<u8>> = vec![obj_to_pod(enum_mjpeg)];
+ let mut enum_pods: Vec<&Pod> = enum_bytes.iter().map(|b| Pod::from_bytes(b).unwrap()).collect();
+
+ if let Err(e) = stream.connect(
+ Direction::Output,
+ None,
+ StreamFlags::MAP_BUFFERS,
+ &mut enum_pods[..],
+ ) {
+ error!("Failed to connect PipeWire stream: {}", e);
+ return;
+ }
+
+ info!("Stream connected successfully");
+ info!("Virtual camera node '{}' is connecting to PipeWire", config.node_name);
+ info!("Other applications can now attempt to negotiate formats");
+
+ // Wait for our node to appear in the registry and capture object.serial
+ let t0 = std::time::Instant::now();
+ while serial_slot.load(AtomicOrdering::SeqCst) == 0 && t0.elapsed() < std::time::Duration::from_millis(1500) {
+ mainloop.loop_().iterate(std::time::Duration::from_millis(10));
+ }
+ let serial_logged = serial_slot.load(AtomicOrdering::SeqCst);
+ if serial_logged != 0 {
+ info!("You can target this node with: target-object={} or target-object={}", serial_logged, config.node_name);
+ } else {
+ warn!("Node serial not observed yet in registry; it may appear a bit later.");
+ }
+
+ // Run main loop until told to stop
+ info!("Starting main loop iteration...");
+ let mut iteration_count = 0;
+ while running.load(Ordering::Relaxed) {
+ iteration_count += 1;
+ if iteration_count % 1000 == 0 {
+ info!("Main loop iteration: {}", iteration_count);
+ }
+
+ // Drive loop
+ let result = mainloop.loop_().iterate(std::time::Duration::from_millis(16));
+ if result < 0 {
+ error!("Main loop iteration failed with result: {}", result);
+ break;
+ }
+ }
+ info!("Main loop exited after {} iterations", iteration_count);
+ }
+
+ /// Start frame processing thread
+ fn start_frame_processor(&mut self) -> Result<()> {
+ let (tx, rx) = mpsc::channel();
+ self.stats_frame_sender = Some(tx); // Use separate sender for stats
+
+ let running = Arc::clone(&self.running);
+ let stats = Arc::clone(&self.stats);
+ let last_frame_time = Arc::clone(&self.last_frame_time);
+
+ thread::spawn(move || {
+ Self::frame_processing_loop(rx, running, stats, last_frame_time);
+ });
+
+ info!("Frame processing thread started");
+ Ok(())
+ }
+
+ /// Frame processing loop that runs in a separate thread
+ fn frame_processing_loop(
+ rx: Receiver<Vec<u8>>,
+ running: Arc<AtomicBool>,
+ stats: Arc<Mutex<VideoStats>>,
+ last_frame_time: Arc<Mutex<Instant>>,
+ ) {
+ while running.load(Ordering::Relaxed) {
+ match rx.recv_timeout(std::time::Duration::from_millis(100)) {
+ Ok(frame_data) => {
+ // Process frame and update statistics
+ Self::update_stats(&stats, &last_frame_time, frame_data.len());
+ trace!("Frame processed: {} bytes", frame_data.len());
+ }
+ Err(mpsc::RecvTimeoutError::Timeout) => {
+ continue;
+ }
+ Err(mpsc::RecvTimeoutError::Disconnected) => {
+ break;
+ }
+ }
+ }
+ }
+
+ /// Update statistics with proper FPS calculation
+ fn update_stats(
+ stats: &Arc<Mutex<VideoStats>>,
+ last_frame_time: &Arc<Mutex<Instant>>,
+ frame_size: usize,
+ ) {
+ let mut stats_guard = stats.lock().unwrap();
+ stats_guard.frames_pushed += 1;
+ stats_guard.total_bytes += frame_size as u64;
+ stats_guard.backend_type = super::VideoBackendType::PipeWire;
+ stats_guard.is_ready = true;
+
+ let now = Instant::now();
+ let mut last_time = last_frame_time.lock().unwrap();
+ let duration = now.duration_since(*last_time);
+ *last_time = now;
+
+ if duration.as_millis() > 0 {
+ stats_guard.fps = 1000.0 / duration.as_millis() as f64;
+ }
+ }
+
+ /// Get current node information for external tools
+ pub fn get_node_info(&self) -> Option<(u32, u32, String)> {
+ // This would need to be implemented with proper synchronization
+ // For now, return the config info
+ Some((
+ self.virtual_node_id.unwrap_or(0),
+ 0, // object.serial - would need to be stored from the stream
+ self.config.node_name.clone(),
+ ))
+ }
+
+ /// Get the object.serial for targeting (if available)
+ pub fn get_object_serial(&self) -> Option<u32> {
+ // This would need to be implemented with proper synchronization
+ // For now, return None - the serial is logged when discovered
+ None
+ }
+
+ /// Check if the virtual camera node is registered and discoverable
+ pub fn is_node_registered(&self) -> bool {
+ self.is_initialized && self.running.load(Ordering::Relaxed)
+ }
+}
+
+impl VideoBackendTrait for PipeWireBackend {
+ fn initialize(&mut self) -> Result<()> {
+ if self.is_initialized {
+ return Ok(());
+ }
+
+ info!("Initializing PipeWire backend with native library...");
+
+ if let Err(e) = self.check_pipewire_available() {
+ error!("PipeWire not available: {}", e);
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ if let Err(e) = self.create_virtual_camera_node() {
+ error!("Failed to create virtual camera node: {}", e);
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ if let Err(e) = self.start_frame_processor() {
+ error!("Failed to start frame processor: {}", e);
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ self.is_initialized = true;
+ self.running.store(true, Ordering::Relaxed);
+ info!("PipeWire backend initialized successfully with native library");
+ // Remove premature logging - the actual node creation will be logged in the PipeWire thread
+ // when the node is actually available
+
+ Ok(())
+ }
+
+ fn push_frame(&self, frame_data: &[u8]) -> Result<()> {
+ if !self.running.load(Ordering::Relaxed) {
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ if !self.is_initialized {
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ trace!("Queueing frame for PipeWire: {} bytes", frame_data.len());
+
+ // Send to PipeWire thread
+ if let Some(sender) = &self.pw_frame_sender {
+ if let Err(e) = sender.send(frame_data.to_vec()) {
+ error!("Failed to queue frame to PipeWire: {}", e);
+ return Err(VideoError::DeviceNotReady.into());
+ }
+ debug!("Frame queued for PipeWire processing: {} bytes", frame_data.len());
+ } else {
+ error!("PipeWire frame sender not available");
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ // Send to stats thread
+ if let Some(sender) = &self.stats_frame_sender {
+ if let Err(e) = sender.send(frame_data.to_vec()) {
+ error!("Failed to queue frame to stats: {}", e);
+ // Don't fail the entire operation for stats
+ }
+ }
+
+ trace!("Frame queued successfully");
+ Ok(())
+ }
+
+ fn get_stats(&self) -> VideoStats {
+ self.stats.lock().unwrap().clone()
+ }
+
+ fn is_ready(&self) -> bool {
+ self.is_initialized && self.running.load(Ordering::Relaxed)
+ }
+
+ fn shutdown(&mut self) -> Result<()> {
+ if !self.is_initialized {
+ return Ok(())
+ }
+
+ info!("Shutting down PipeWire backend...");
+
+ self.running.store(false, Ordering::Relaxed);
+
+ if let Some(handle) = self.pw_thread.take() {
+ if let Err(e) = handle.join() {
+ error!("Error joining PipeWire thread: {:?}", e);
+ }
+ }
+
+ self.virtual_node_id = None;
+ self.pw_frame_sender = None;
+ self.stats_frame_sender = None;
+
+ self.is_initialized = false;
+ info!("PipeWire backend shut down successfully");
+
+ Ok(())
+ }
+}
+
+impl Drop for PipeWireBackend {
+ fn drop(&mut self) {
+ self.running.store(false, Ordering::Relaxed);
+
+ if let Some(handle) = self.pw_thread.take() {
+ let _ = handle.join();
+ }
+
+ // Note: frame_senders will be dropped automatically
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_pipewire_backend_creation() {
+ let config = PipeWireConfig::default();
+ let backend = PipeWireBackend::new(config);
+
+ assert_eq!(backend.config.node_name, "geek-szitman-supercamera");
+ assert_eq!(backend.config.description, "Geek Szitman SuperCamera - High-quality virtual camera for streaming and recording");
+ assert_eq!(backend.config.media_class, "Video/Source");
+ assert_eq!(backend.config.format, VideoFormat::MJPEG);
+ assert_eq!(backend.config.width, 640);
+ assert_eq!(backend.config.height, 480);
+ assert_eq!(backend.config.framerate, 30);
+ }
+
+ #[test]
+ fn test_pipewire_backend_default_config() {
+ let config = PipeWireConfig::default();
+ let backend = PipeWireBackend::new(config);
+
+ assert!(!backend.is_initialized);
+ assert!(!backend.is_ready());
+ }
+}
diff --git a/src/video/stdout.rs b/src/video/stdout.rs
new file mode 100644
index 0000000..ce9882a
--- /dev/null
+++ b/src/video/stdout.rs
@@ -0,0 +1,277 @@
+//! Stdout video backend for piping video output to other tools
+
+use crate::error::{Result, VideoError};
+use crate::video::{VideoBackendTrait, VideoStats};
+use std::io::{self, Write};
+use std::sync::Mutex;
+use tracing::{debug, info};
+
+/// Configuration for the stdout video backend
+#[derive(Debug, Clone)]
+pub struct StdoutConfig {
+ /// Whether to output frame headers with metadata
+ pub include_headers: bool,
+ /// Whether to flush stdout after each frame
+ pub flush_after_frame: bool,
+ /// Output format for headers (if enabled)
+ pub header_format: HeaderFormat,
+}
+
+impl Default for StdoutConfig {
+ fn default() -> Self {
+ Self {
+ include_headers: false,
+ flush_after_frame: true,
+ header_format: HeaderFormat::Simple,
+ }
+ }
+}
+
+/// Header format for frame metadata
+#[derive(Debug, Clone)]
+pub enum HeaderFormat {
+ /// Simple text format: "FRAME:size:timestamp\n"
+ Simple,
+ /// JSON format: {"frame": {"size": size, "timestamp": timestamp}}
+ Json,
+ /// Binary format: 4-byte size + 8-byte timestamp
+ Binary,
+}
+
+/// Stdout video backend that outputs raw video frames to stdout
+pub struct StdoutBackend {
+ config: StdoutConfig,
+ stats: Mutex<VideoStats>,
+ frame_count: Mutex<u64>,
+ start_time: Mutex<std::time::Instant>,
+}
+
+impl StdoutBackend {
+ /// Create a new stdout backend with default configuration
+ pub fn new() -> Self {
+ Self {
+ config: StdoutConfig::default(),
+ stats: Mutex::new(VideoStats::default()),
+ frame_count: Mutex::new(0),
+ start_time: Mutex::new(std::time::Instant::now()),
+ }
+ }
+
+ /// Create a new stdout backend with custom configuration
+ pub fn with_config(config: StdoutConfig) -> Self {
+ Self {
+ config,
+ stats: Mutex::new(VideoStats::default()),
+ frame_count: Mutex::new(0),
+ start_time: Mutex::new(std::time::Instant::now()),
+ }
+ }
+
+ /// Write frame header if enabled
+ fn write_frame_header(&self, frame_size: usize) -> Result<()> {
+ if !self.config.include_headers {
+ return Ok(());
+ }
+
+ let start_time = self.start_time.lock().unwrap();
+ let timestamp = start_time.elapsed().as_micros();
+ drop(start_time);
+
+ match self.config.header_format {
+ HeaderFormat::Simple => {
+ let header = format!("FRAME:{}:{}\n", frame_size, timestamp);
+ io::stdout().write_all(header.as_bytes())?;
+ }
+ HeaderFormat::Json => {
+ let header = format!(
+ r#"{{"frame": {{"size": {}, "timestamp": {}}}}}{}"#,
+ frame_size, timestamp, '\n'
+ );
+ io::stdout().write_all(header.as_bytes())?;
+ }
+ HeaderFormat::Binary => {
+ // 4-byte size + 8-byte timestamp
+ let size_bytes = (frame_size as u32).to_le_bytes();
+ let timestamp_bytes = timestamp.to_le_bytes();
+ let mut header = Vec::with_capacity(12);
+ header.extend_from_slice(&size_bytes);
+ header.extend_from_slice(&timestamp_bytes);
+ io::stdout().write_all(&header)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Update statistics
+ fn update_stats(&self, frame_size: usize) {
+ let mut frame_count = self.frame_count.lock().unwrap();
+ *frame_count += 1;
+ let current_frame_count = *frame_count;
+ drop(frame_count);
+
+ let mut stats = self.stats.lock().unwrap();
+ stats.frames_pushed = current_frame_count;
+ stats.total_bytes += frame_size as u64;
+
+ // Calculate FPS based on elapsed time
+ let start_time = self.start_time.lock().unwrap();
+ let elapsed = start_time.elapsed().as_secs_f64();
+ drop(start_time);
+
+ if elapsed > 0.0 {
+ stats.fps = current_frame_count as f64 / elapsed;
+ }
+
+ // Log frame information for debugging
+ if current_frame_count % 30 == 0 { // Log every 30 frames
+ tracing::debug!(
+ "Stdout backend: frame {}, size: {} bytes, total: {} bytes, fps: {:.2}",
+ current_frame_count, frame_size, stats.total_bytes, stats.fps
+ );
+ }
+ }
+}
+
+impl VideoBackendTrait for StdoutBackend {
+ fn initialize(&mut self) -> Result<()> {
+ info!("Initializing stdout video backend");
+ {
+ let mut start_time = self.start_time.lock().unwrap();
+ *start_time = std::time::Instant::now();
+ }
+ {
+ let mut stats = self.stats.lock().unwrap();
+ stats.is_ready = true;
+ }
+
+ // Write initial header if enabled
+ if self.config.include_headers {
+ match self.config.header_format {
+ HeaderFormat::Simple => {
+ io::stdout().write_all(b"STDOUT_VIDEO_STREAM_START\n")?;
+ }
+ HeaderFormat::Json => {
+ io::stdout().write_all(b"{\"stream\": \"start\"}\n")?;
+ }
+ HeaderFormat::Binary => {
+ // Magic number: "STDO" (4 bytes)
+ io::stdout().write_all(b"STDO")?;
+ }
+ }
+ }
+
+ debug!("Stdout video backend initialized successfully");
+ Ok(())
+ }
+
+ fn push_frame(&self, frame_data: &[u8]) -> Result<()> {
+ let stats = self.stats.lock().unwrap();
+ if !stats.is_ready {
+ return Err(VideoError::DeviceNotReady.into());
+ }
+ drop(stats);
+
+ // Write frame header if enabled
+ self.write_frame_header(frame_data.len())?;
+
+ // Write frame data to stdout
+ io::stdout().write_all(frame_data)?;
+
+ // Flush if configured
+ if self.config.flush_after_frame {
+ io::stdout().flush()?;
+ }
+
+ // Update statistics
+ self.update_stats(frame_data.len());
+ debug!("Pushed frame to stdout: {} bytes", frame_data.len());
+ Ok(())
+ }
+
+ fn get_stats(&self) -> VideoStats {
+ let mut stats = self.stats.lock().unwrap().clone();
+ stats.backend_type = crate::video::VideoBackendType::Stdout;
+ stats
+ }
+
+ fn is_ready(&self) -> bool {
+ self.stats.lock().unwrap().is_ready
+ }
+
+ fn shutdown(&mut self) -> Result<()> {
+ info!("Shutting down stdout video backend");
+
+ // Write final header if enabled
+ if self.config.include_headers {
+ match self.config.header_format {
+ HeaderFormat::Simple => {
+ io::stdout().write_all(b"STDOUT_VIDEO_STREAM_END\n")?;
+ }
+ HeaderFormat::Json => {
+ io::stdout().write_all(b"{\"stream\": \"end\"}\n")?;
+ }
+ HeaderFormat::Binary => {
+ // End marker: "END\0" (4 bytes)
+ io::stdout().write_all(b"END\0")?;
+ }
+ }
+ }
+
+ // Final flush
+ io::stdout().flush()?;
+
+ {
+ let mut stats = self.stats.lock().unwrap();
+ stats.is_ready = false;
+ }
+ Ok(())
+ }
+}
+
+impl Drop for StdoutBackend {
+ fn drop(&mut self) {
+ let is_ready = {
+ let stats = self.stats.lock().unwrap();
+ stats.is_ready
+ };
+ if is_ready {
+ let _ = self.shutdown();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::video::VideoBackendTrait;
+
+ #[test]
+ fn test_stdout_backend_creation() {
+ let backend = StdoutBackend::new();
+ assert!(!backend.is_ready());
+ assert_eq!(backend.get_stats().frames_pushed, 0);
+ }
+
+ #[test]
+ fn test_stdout_backend_with_config() {
+ let config = StdoutConfig {
+ include_headers: true,
+ flush_after_frame: false,
+ header_format: HeaderFormat::Json,
+ };
+ let backend = StdoutBackend::with_config(config);
+ assert!(!backend.is_ready());
+ }
+
+ #[test]
+ fn test_header_format_creation() {
+ let simple = HeaderFormat::Simple;
+ let json = HeaderFormat::Json;
+ let binary = HeaderFormat::Binary;
+
+ // Just test that they can be created
+ assert!(matches!(simple, HeaderFormat::Simple));
+ assert!(matches!(json, HeaderFormat::Json));
+ assert!(matches!(binary, HeaderFormat::Binary));
+ }
+}
diff --git a/src/video/v4l2.rs b/src/video/v4l2.rs
new file mode 100644
index 0000000..792ff51
--- /dev/null
+++ b/src/video/v4l2.rs
@@ -0,0 +1,321 @@
+//! V4L2 backend for video streaming (placeholder for future implementation)
+
+use super::{VideoBackendTrait, VideoStats, VideoFormat};
+use crate::error::{Result, VideoError};
+use std::sync::Arc;
+use std::sync::Mutex;
+use tracing::{debug, info, trace, warn};
+
+/// V4L2 backend implementation (placeholder)
+pub struct V4L2Backend {
+ device_path: String,
+ is_initialized: bool,
+ stats: Arc<Mutex<VideoStats>>,
+ config: V4L2Config,
+}
+
+/// V4L2 configuration
+#[derive(Debug, Clone)]
+pub struct V4L2Config {
+ pub device_path: String,
+ pub width: u32,
+ pub height: u32,
+ pub fps: u32,
+ pub format: VideoFormat,
+ pub buffer_size: usize,
+}
+
+impl Default for V4L2Config {
+ fn default() -> Self {
+ Self {
+ device_path: "/dev/video10".to_string(),
+ width: 640,
+ height: 480,
+ fps: 30,
+ format: VideoFormat::MJPEG,
+ buffer_size: 0x10000, // 64KB
+ }
+ }
+}
+
+impl V4L2Backend {
+ /// Create a new V4L2 backend
+ pub fn new() -> Result<Self> {
+ let stats = VideoStats {
+ backend_type: super::VideoBackendType::V4L2,
+ ..Default::default()
+ };
+
+ Ok(Self {
+ device_path: "/dev/video10".to_string(),
+ is_initialized: false,
+ stats: Arc::new(Mutex::new(stats)),
+ config: V4L2Config::default(),
+ })
+ }
+
+ /// Create a new V4L2 backend with custom configuration
+ pub fn with_config(config: V4L2Config) -> Result<Self> {
+ let stats = VideoStats {
+ backend_type: super::VideoBackendType::V4L2,
+ ..Default::default()
+ };
+
+ Ok(Self {
+ device_path: config.device_path.clone(),
+ is_initialized: false,
+ stats: Arc::new(Mutex::new(stats)),
+ config,
+ })
+ }
+
+ /// Check if V4L2 device exists and is accessible
+ fn check_device(&self) -> Result<()> {
+ use std::path::Path;
+
+ if !Path::new(&self.device_path).exists() {
+ return Err(VideoError::V4L2(format!("Device not found: {}", self.device_path)).into());
+ }
+
+ // TODO: Check device permissions and capabilities
+ debug!("V4L2 device found: {}", self.device_path);
+ Ok(())
+ }
+
+ /// Update statistics
+ fn update_stats(&self, frame_size: usize) {
+ let mut stats = self.stats.lock().unwrap();
+ stats.frames_pushed += 1;
+ stats.total_bytes += frame_size as u64;
+ stats.backend_type = super::VideoBackendType::V4L2;
+ stats.is_ready = self.is_initialized;
+
+ // Calculate FPS (simple rolling average)
+ // TODO: Implement proper FPS calculation
+ stats.fps = 30.0; // Placeholder
+ }
+}
+
+impl VideoBackendTrait for V4L2Backend {
+ fn initialize(&mut self) -> Result<()> {
+ if self.is_initialized {
+ warn!("V4L2 backend already initialized");
+ return Ok(());
+ }
+
+ info!("Initializing V4L2 backend...");
+
+ // Check if device exists and is accessible
+ if let Err(e) = self.check_device() {
+ warn!("V4L2 device check failed: {}", e);
+ return Err(e);
+ }
+
+ // TODO: Implement actual V4L2 device initialization
+ // For now, this is a placeholder that simulates success
+ debug!("V4L2 initialization (placeholder) - would open device: {}", self.device_path);
+ debug!("Format: {}x{} @ {}fps ({})",
+ self.config.width,
+ self.config.height,
+ self.config.fps,
+ self.config.format.as_str());
+
+ self.is_initialized = true;
+ info!("V4L2 backend initialized successfully (placeholder)");
+
+ Ok(())
+ }
+
+ fn push_frame(&self, frame_data: &[u8]) -> Result<()> {
+ if !self.is_initialized {
+ return Err(VideoError::DeviceNotReady.into());
+ }
+
+ trace!("Pushing frame to V4L2 (placeholder): {} bytes", frame_data.len());
+
+ // TODO: Implement actual frame pushing to V4L2
+ // For now, this is a placeholder that simulates success
+ debug!("Would push frame of {} bytes to V4L2 device: {}", frame_data.len(), self.device_path);
+
+ // Update statistics
+ self.update_stats(frame_data.len());
+
+ trace!("Frame processed successfully (placeholder)");
+ Ok(())
+ }
+
+ fn get_stats(&self) -> VideoStats {
+ self.stats.lock().unwrap().clone()
+ }
+
+ fn is_ready(&self) -> bool {
+ self.is_initialized
+ }
+
+ fn shutdown(&mut self) -> Result<()> {
+ if !self.is_initialized {
+ return Ok(());
+ }
+
+ info!("Shutting down V4L2 backend (placeholder)...");
+
+ // TODO: Implement actual V4L2 device cleanup
+ // For now, this is a placeholder that simulates success
+
+ self.is_initialized = false;
+ info!("V4L2 backend shut down successfully (placeholder)");
+
+ Ok(())
+ }
+}
+
+impl Drop for V4L2Backend {
+ fn drop(&mut self) {
+ if self.is_initialized {
+ // Try to shutdown gracefully (synchronous)
+ let _ = self.shutdown();
+ }
+ }
+}
+
+/// V4L2 device information (placeholder)
+#[derive(Debug, Clone)]
+pub struct V4L2DeviceInfo {
+ pub device_path: String,
+ pub driver_name: String,
+ pub card_name: String,
+ pub bus_info: String,
+ pub capabilities: u32,
+}
+
+impl V4L2DeviceInfo {
+ /// Create new device info
+ pub fn new(device_path: String) -> Self {
+ Self {
+ device_path,
+ driver_name: "Unknown".to_string(),
+ card_name: "Unknown".to_string(),
+ bus_info: "Unknown".to_string(),
+ capabilities: 0,
+ }
+ }
+
+ /// Check if device supports video output
+ pub fn supports_video_output(&self) -> bool {
+ // TODO: Implement capability checking
+ true
+ }
+
+ /// Check if device supports MJPEG format
+ pub fn supports_mjpeg(&self) -> bool {
+ // TODO: Implement format checking
+ true
+ }
+}
+
+/// V4L2 format information (placeholder)
+#[derive(Debug, Clone)]
+pub struct V4L2Format {
+ pub width: u32,
+ pub height: u32,
+ pub pixel_format: u32,
+ pub field: u32,
+ pub bytes_per_line: u32,
+ pub size_image: u32,
+ pub colorspace: u32,
+}
+
+impl V4L2Format {
+ /// Create new format
+ pub fn new(width: u32, height: u32, pixel_format: u32) -> Self {
+ Self {
+ width,
+ height,
+ pixel_format,
+ field: 1, // V4L2_FIELD_NONE
+ bytes_per_line: 0,
+ size_image: width * height * 2, // Estimate for MJPEG
+ colorspace: 1, // V4L2_COLORSPACE_SMPTE170M
+ }
+ }
+
+ /// Get format description
+ pub fn description(&self) -> String {
+ format!("{}x{} @ {} bytes", self.width, self.height, self.size_image)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_v4l2_config_default() {
+ let config = V4L2Config::default();
+ assert_eq!(config.device_path, "/dev/video10");
+ assert_eq!(config.width, 640);
+ assert_eq!(config.height, 480);
+ assert_eq!(config.fps, 30);
+ assert!(matches!(config.format, VideoFormat::MJPEG));
+ assert_eq!(config.buffer_size, 0x10000);
+ }
+
+ #[test]
+ fn test_v4l2_backend_creation() {
+ let backend = V4L2Backend::new();
+ assert!(backend.is_ok());
+
+ let backend = backend.unwrap();
+ assert!(!backend.is_initialized);
+ assert_eq!(backend.device_path, "/dev/video10");
+ }
+
+ #[test]
+ fn test_v4l2_backend_with_config() {
+ let config = V4L2Config {
+ device_path: "/dev/video20".to_string(),
+ width: 1280,
+ height: 720,
+ fps: 60,
+ ..Default::default()
+ };
+
+ let backend = V4L2Backend::with_config(config);
+ assert!(backend.is_ok());
+ }
+
+ #[test]
+ fn test_v4l2_device_info() {
+ let device_info = V4L2DeviceInfo::new("/dev/video10".to_string());
+ assert_eq!(device_info.device_path, "/dev/video10");
+ assert_eq!(device_info.driver_name, "Unknown");
+ assert!(device_info.supports_video_output());
+ assert!(device_info.supports_mjpeg());
+ }
+
+ #[test]
+ fn test_v4l2_format() {
+ let format = V4L2Format::new(640, 480, 0x47504A4D); // MJPEG
+ assert_eq!(format.width, 640);
+ assert_eq!(format.height, 480);
+ assert_eq!(format.pixel_format, 0x47504A4D);
+ assert_eq!(format.description(), "640x480 @ 614400 bytes");
+ }
+
+ #[test]
+ fn test_v4l2_backend_stats() {
+ let backend = V4L2Backend::new().unwrap();
+ let stats = backend.get_stats();
+
+ assert_eq!(stats.frames_pushed, 0);
+ assert_eq!(stats.total_bytes, 0);
+ assert!(!stats.is_ready);
+ assert!(matches!(stats.backend_type, super::super::VideoBackendType::V4L2));
+ }
+
+ #[test]
+ fn test_v4l2_backend_ready_state() {
+ let backend = V4L2Backend::new().unwrap();
+ assert!(!backend.is_ready());
+ }
+}
diff --git a/tests/integration_test.rs b/tests/integration_test.rs
new file mode 100644
index 0000000..f7b3542
--- /dev/null
+++ b/tests/integration_test.rs
@@ -0,0 +1,227 @@
+//! Integration tests for the geek-szitman-supercamera crate
+
+use geek_szitman_supercamera::{UPPCamera, VideoBackend};
+
+/// Test camera creation without actual USB device
+#[test]
+fn test_camera_creation() {
+ // This test will fail without actual USB device, but we can test the structure
+ // In a real test environment, we'd use mocks
+ assert!(true);
+}
+
+/// Test UPP camera protocol handling
+#[test]
+fn test_upp_camera_protocol() {
+ let camera = UPPCamera::new();
+
+ // Test initial state
+ let stats = camera.get_stats();
+ assert_eq!(stats.buffer_size, 0);
+ assert!(stats.current_frame_id.is_none());
+
+ // Test frame buffer management
+ camera.clear_frame_buffer();
+ let frame = camera.get_complete_frame();
+ assert!(frame.is_none());
+}
+
+/// Test video backend creation
+#[test]
+fn test_video_backend_creation() {
+ // Test PipeWire backend creation (may fail without PipeWire)
+ let _pipewire_result = VideoBackend::new_pipewire();
+ // assert!(_pipewire_result.is_ok()); // Uncomment when PipeWire is available
+
+ // Test V4L2 backend creation (placeholder)
+ let v4l2_result = VideoBackend::new_v4l2();
+ assert!(v4l2_result.is_ok());
+}
+
+/// Test error handling
+#[test]
+fn test_error_types() {
+ use geek_szitman_supercamera::error::*;
+
+ // Test USB errors
+ let usb_error = UsbError::DeviceNotFound;
+ assert!(matches!(usb_error, UsbError::DeviceNotFound));
+
+ // Test video errors
+ let video_error = VideoError::PipeWire("test".to_string());
+ assert!(matches!(video_error, VideoError::PipeWire(_)));
+
+ // Test protocol errors
+ let protocol_error = ProtocolError::InvalidFrameFormat("test".to_string());
+ assert!(matches!(
+ protocol_error,
+ ProtocolError::InvalidFrameFormat(_)
+ ));
+}
+
+/// Test configuration loading
+#[test]
+fn test_config_loading() {
+ use serde::{Deserialize, Serialize};
+
+ #[derive(Debug, Serialize, Deserialize, PartialEq)]
+ struct TestConfig {
+ name: String,
+ value: u32,
+ }
+
+ let _config = TestConfig {
+ name: "test".to_string(),
+ value: 42,
+ };
+
+ // Test saving and loading (this will fail in test environment, but we can test the structure)
+ // let temp_path = "/tmp/test_config.json";
+ // ConfigLoader::save_to_file(temp_path, &_config).unwrap();
+ // let loaded_config = ConfigLoader::load_from_file::<TestConfig>(temp_path).unwrap();
+ // assert_eq!(_config, loaded_config);
+
+ assert!(true);
+}
+
+/// Test performance tracking
+#[test]
+fn test_performance_tracking() {
+ use geek_szitman_supercamera::utils::PerformanceTracker;
+
+ let mut tracker = PerformanceTracker::new();
+
+ // Record some frames
+ tracker.record_frame(1024);
+ tracker.record_frame(2048);
+
+ let summary = tracker.summary();
+ assert_eq!(summary.total_frames, 2);
+ assert_eq!(summary.total_bytes, 3072);
+ assert_eq!(summary.average_frame_size, 1536.0);
+}
+
+/// Test file utilities
+#[test]
+fn test_file_utilities() {
+ use geek_szitman_supercamera::utils::FileUtils;
+
+ // Test file existence
+ assert!(FileUtils::file_exists("tests/integration_test.rs"));
+ assert!(!FileUtils::file_exists("nonexistent_file.txt"));
+
+ // Test extension extraction
+ assert_eq!(
+ FileUtils::get_extension("test.txt"),
+ Some("txt".to_string())
+ );
+ assert_eq!(FileUtils::get_extension("no_extension"), None);
+}
+
+/// Test time utilities
+#[test]
+fn test_time_utilities() {
+ use geek_szitman_supercamera::utils::TimeUtils;
+ use std::time::Duration;
+
+ let duration = Duration::from_millis(1500);
+ let formatted = TimeUtils::format_duration(duration);
+ assert!(formatted.contains("1.500s"));
+
+ let short_duration = Duration::from_millis(500);
+ let formatted = TimeUtils::format_duration(short_duration);
+ assert!(formatted.contains("500ms"));
+}
+
+/// Test async utilities
+#[test]
+fn test_async_utilities() {
+ use geek_szitman_supercamera::utils::TimeUtils;
+ use std::time::Duration;
+
+ let mut progress_values = Vec::new();
+ let duration = Duration::from_millis(100);
+
+ TimeUtils::sleep_with_progress(duration, |progress| {
+ progress_values.push(progress);
+ });
+
+ assert!(!progress_values.is_empty());
+ assert!(progress_values.last().unwrap() >= &1.0);
+}
+
+/// Test error conversion
+#[test]
+fn test_error_conversion() {
+ use geek_szitman_supercamera::error::*;
+
+ // Test String conversion
+ let string_error: Error = "test error".to_string().into();
+ assert!(matches!(string_error, Error::Generic(_)));
+
+ // Test &str conversion
+ let str_error: Error = "test error".into();
+ assert!(matches!(str_error, Error::Generic(_)));
+}
+
+/// Test video format handling
+#[test]
+fn test_video_formats() {
+ use geek_szitman_supercamera::video::VideoFormat;
+
+ let mjpeg = VideoFormat::MJPEG;
+ assert_eq!(mjpeg.as_str(), "MJPEG");
+ assert_eq!(mjpeg.bytes_per_pixel(), 0);
+
+ let yuv420 = VideoFormat::YUV420;
+ assert_eq!(yuv420.as_str(), "YUV420");
+ assert_eq!(yuv420.bytes_per_pixel(), 1);
+
+ let rgb24 = VideoFormat::RGB24;
+ assert_eq!(rgb24.as_str(), "RGB24");
+ assert_eq!(rgb24.bytes_per_pixel(), 3);
+}
+
+/// Test video configuration
+#[test]
+fn test_video_config() {
+ use geek_szitman_supercamera::video::{VideoBackendType, VideoConfig, VideoFormat};
+
+ let config = VideoConfig::default();
+ assert!(matches!(config.backend_type, VideoBackendType::PipeWire));
+ assert_eq!(config.width, 640);
+ assert_eq!(config.height, 480);
+ assert_eq!(config.fps, 30);
+ assert!(matches!(config.format, VideoFormat::MJPEG));
+}
+
+/// Test video frame creation
+#[test]
+fn test_video_frame() {
+ use geek_szitman_supercamera::video::{VideoFormat, VideoFrame};
+
+ let data = vec![1, 2, 3, 4, 5];
+ let frame = VideoFrame::new(data.clone(), 640, 480, VideoFormat::MJPEG);
+
+ assert_eq!(frame.data, data);
+ assert_eq!(frame.width, 640);
+ assert_eq!(frame.height, 480);
+ assert!(matches!(frame.format, VideoFormat::MJPEG));
+ assert!(frame.is_valid());
+ assert_eq!(frame.size(), 5);
+ assert_eq!(frame.dimensions(), (640, 480));
+}
+
+/// Test video statistics
+#[test]
+fn test_video_stats() {
+ use geek_szitman_supercamera::video::{VideoBackendType, VideoStats};
+
+ let stats = VideoStats::default();
+ assert_eq!(stats.frames_pushed, 0);
+ assert_eq!(stats.frames_dropped, 0);
+ assert_eq!(stats.total_bytes, 0);
+ assert_eq!(stats.fps, 0.0);
+ assert!(matches!(stats.backend_type, VideoBackendType::PipeWire));
+ assert!(!stats.is_ready);
+}