A facial recognition login service for Linux.
2

Configure Feed

Select the types of activity you want to include in your feed.

core: Add V4L2-backed FrameSource and camera enumeration

First hardware-touching FrameSource. Wraps the `v4l` crate to expose Linux
V4L2 capture through the same trait the mocks use. The two public entry
points cover the only operations the daemon needs:
- `enumerate()` walks /dev/video* and returns the cameras it can see.
- `V4l2FrameSource::open()` configures a specific device for capture.

# Exclusivity

V4L2 capture devices are exclusive while buffers are allocated: any other
process trying to open the same /dev/video* node gets EBUSY. The daemon's
intended pattern is therefore open-on-demand (per PAM auth request) and
drop-immediately-after so the camera stays available to other applications.
Drop cleanup must actually run — the v4l crate's `Stream<'a>` is
conservatively lifetime-bound to its `&Device`, so we use `self_cell` to
own both together and drop them in the right order (Stream first, then
Device).

# Feature shape

V4L2 is required for Pareidolia to actually run on a real system, so the
daemon's default feature set includes `v4l2` and the binary picks it up
out of the box. `pareidolia-core` keeps `v4l2` opt-in (default = []) so
consumers that only need the pipeline (mocks, format conversions) don't pay
the libv4l / bindgen build cost. CI passes `--no-default-features` to
disable v4l2 everywhere, which avoids needing libv4l in its Nixery image.

The workspace.dependencies entries set default-features = false on all the
internal crates so daemon's v4l2 doesn't transitively re-enable itself for
unrelated consumers (integration tests, the PAM module) through the dep
graph — a quirk of `cargo --workspace` feature unification.

A heuristic guesses IR vs RGB by checking whether the device advertises any
colour FourCCs (YUYV / RGB3 / MJPG). It's intentionally crude; the daemon's
config can override per device.

Isaac Corbrey (May 27, 2026, 10:46 AM EDT) 9106f35c 44a8a268

+766 -19
+4
.tangled/workflows/ci-fast.yml
··· 30 30 RUST_BACKTRACE: "1" 31 31 # Quiet down rustup if it's somehow on PATH; we use system rustc here. 32 32 RUSTUP_TOOLCHAIN: "" 33 + # pareidolia-core's `v4l2` feature is on by default, but enabling it pulls 34 + # in libv4l + bindgen prerequisites that our Nixery image doesn't ship. 35 + # CI builds the no-v4l2 profile; local dev gets full defaults. 36 + CARGO_ARGS: "--no-default-features" 33 37 34 38 steps: 35 39 - name: "rustfmt"
+2
.tangled/workflows/ci-slow.yml
··· 28 28 CARGO_INCREMENTAL: "0" 29 29 RUST_BACKTRACE: "1" 30 30 RUSTUP_TOOLCHAIN: "" 31 + # See ci-fast.yml for rationale: CI builds the no-v4l2 profile. 32 + CARGO_ARGS: "--no-default-features" 31 33 32 34 steps: 33 35 - name: "integration tests"
+300 -10
Cargo.lock
··· 1 1 # This file is automatically @generated by Cargo. 2 2 # It is not intended for manual editing. 3 - version = 3 3 + version = 4 4 + 5 + [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 4 13 5 14 [[package]] 6 15 name = "anyhow" ··· 15 24 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" 16 25 17 26 [[package]] 27 + name = "bindgen" 28 + version = "0.65.1" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" 31 + dependencies = [ 32 + "bitflags 1.3.2", 33 + "cexpr", 34 + "clang-sys", 35 + "lazy_static", 36 + "lazycell", 37 + "log", 38 + "peeking_take_while", 39 + "prettyplease", 40 + "proc-macro2", 41 + "quote", 42 + "regex", 43 + "rustc-hash", 44 + "shlex", 45 + "syn", 46 + "which", 47 + ] 48 + 49 + [[package]] 18 50 name = "bit-set" 19 51 version = "0.8.0" 20 52 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 31 63 32 64 [[package]] 33 65 name = "bitflags" 66 + version = "1.3.2" 67 + source = "registry+https://github.com/rust-lang/crates.io-index" 68 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 69 + 70 + [[package]] 71 + name = "bitflags" 34 72 version = "2.11.1" 35 73 source = "registry+https://github.com/rust-lang/crates.io-index" 36 74 checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 75 + 76 + [[package]] 77 + name = "cexpr" 78 + version = "0.6.0" 79 + source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" 81 + dependencies = [ 82 + "nom", 83 + ] 37 84 38 85 [[package]] 39 86 name = "cfg-if" ··· 42 89 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 43 90 44 91 [[package]] 92 + name = "clang-sys" 93 + version = "1.8.1" 94 + source = "registry+https://github.com/rust-lang/crates.io-index" 95 + checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" 96 + dependencies = [ 97 + "glob", 98 + "libc", 99 + "libloading", 100 + ] 101 + 102 + [[package]] 103 + name = "either" 104 + version = "1.16.0" 105 + source = "registry+https://github.com/rust-lang/crates.io-index" 106 + checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" 107 + 108 + [[package]] 45 109 name = "equivalent" 46 110 version = "1.0.2" 47 111 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 54 118 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 55 119 dependencies = [ 56 120 "libc", 57 - "windows-sys", 121 + "windows-sys 0.61.2", 58 122 ] 59 123 60 124 [[package]] ··· 101 165 ] 102 166 103 167 [[package]] 168 + name = "glob" 169 + version = "0.3.3" 170 + source = "registry+https://github.com/rust-lang/crates.io-index" 171 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 172 + 173 + [[package]] 104 174 name = "hashbrown" 105 175 version = "0.15.5" 106 176 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 122 192 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 123 193 124 194 [[package]] 195 + name = "home" 196 + version = "0.5.12" 197 + source = "registry+https://github.com/rust-lang/crates.io-index" 198 + checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" 199 + dependencies = [ 200 + "windows-sys 0.61.2", 201 + ] 202 + 203 + [[package]] 125 204 name = "id-arena" 126 205 version = "2.3.0" 127 206 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 146 225 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 147 226 148 227 [[package]] 228 + name = "lazy_static" 229 + version = "1.5.0" 230 + source = "registry+https://github.com/rust-lang/crates.io-index" 231 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 232 + 233 + [[package]] 234 + name = "lazycell" 235 + version = "1.3.0" 236 + source = "registry+https://github.com/rust-lang/crates.io-index" 237 + checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" 238 + 239 + [[package]] 149 240 name = "leb128fmt" 150 241 version = "0.1.0" 151 242 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 158 249 checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 159 250 160 251 [[package]] 252 + name = "libloading" 253 + version = "0.8.9" 254 + source = "registry+https://github.com/rust-lang/crates.io-index" 255 + checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" 256 + dependencies = [ 257 + "cfg-if", 258 + "windows-link", 259 + ] 260 + 261 + [[package]] 262 + name = "linux-raw-sys" 263 + version = "0.4.15" 264 + source = "registry+https://github.com/rust-lang/crates.io-index" 265 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 266 + 267 + [[package]] 161 268 name = "linux-raw-sys" 162 269 version = "0.12.1" 163 270 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 176 283 checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" 177 284 178 285 [[package]] 286 + name = "minimal-lexical" 287 + version = "0.2.1" 288 + source = "registry+https://github.com/rust-lang/crates.io-index" 289 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 290 + 291 + [[package]] 292 + name = "nom" 293 + version = "7.1.3" 294 + source = "registry+https://github.com/rust-lang/crates.io-index" 295 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 296 + dependencies = [ 297 + "memchr", 298 + "minimal-lexical", 299 + ] 300 + 301 + [[package]] 179 302 name = "num-traits" 180 303 version = "0.2.19" 181 304 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 203 326 version = "0.0.0" 204 327 dependencies = [ 205 328 "proptest", 329 + "self_cell", 206 330 "thiserror", 331 + "v4l", 207 332 ] 208 333 209 334 [[package]] ··· 229 354 dependencies = [ 230 355 "pareidolia-core", 231 356 ] 357 + 358 + [[package]] 359 + name = "peeking_take_while" 360 + version = "0.1.2" 361 + source = "registry+https://github.com/rust-lang/crates.io-index" 362 + checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" 232 363 233 364 [[package]] 234 365 name = "ppv-lite86" ··· 266 397 dependencies = [ 267 398 "bit-set", 268 399 "bit-vec", 269 - "bitflags", 400 + "bitflags 2.11.1", 270 401 "num-traits", 271 402 "rand", 272 403 "rand_chacha", ··· 343 474 ] 344 475 345 476 [[package]] 477 + name = "regex" 478 + version = "1.12.3" 479 + source = "registry+https://github.com/rust-lang/crates.io-index" 480 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" 481 + dependencies = [ 482 + "aho-corasick", 483 + "memchr", 484 + "regex-automata", 485 + "regex-syntax", 486 + ] 487 + 488 + [[package]] 489 + name = "regex-automata" 490 + version = "0.4.14" 491 + source = "registry+https://github.com/rust-lang/crates.io-index" 492 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 493 + dependencies = [ 494 + "aho-corasick", 495 + "memchr", 496 + "regex-syntax", 497 + ] 498 + 499 + [[package]] 346 500 name = "regex-syntax" 347 501 version = "0.8.10" 348 502 source = "registry+https://github.com/rust-lang/crates.io-index" 349 503 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 350 504 351 505 [[package]] 506 + name = "rustc-hash" 507 + version = "1.1.0" 508 + source = "registry+https://github.com/rust-lang/crates.io-index" 509 + checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 510 + 511 + [[package]] 512 + name = "rustix" 513 + version = "0.38.44" 514 + source = "registry+https://github.com/rust-lang/crates.io-index" 515 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 516 + dependencies = [ 517 + "bitflags 2.11.1", 518 + "errno", 519 + "libc", 520 + "linux-raw-sys 0.4.15", 521 + "windows-sys 0.59.0", 522 + ] 523 + 524 + [[package]] 352 525 name = "rustix" 353 526 version = "1.1.4" 354 527 source = "registry+https://github.com/rust-lang/crates.io-index" 355 528 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 356 529 dependencies = [ 357 - "bitflags", 530 + "bitflags 2.11.1", 358 531 "errno", 359 532 "libc", 360 - "linux-raw-sys", 361 - "windows-sys", 533 + "linux-raw-sys 0.12.1", 534 + "windows-sys 0.61.2", 362 535 ] 363 536 364 537 [[package]] ··· 372 545 "tempfile", 373 546 "wait-timeout", 374 547 ] 548 + 549 + [[package]] 550 + name = "self_cell" 551 + version = "1.2.2" 552 + source = "registry+https://github.com/rust-lang/crates.io-index" 553 + checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" 375 554 376 555 [[package]] 377 556 name = "semver" ··· 422 601 ] 423 602 424 603 [[package]] 604 + name = "shlex" 605 + version = "1.3.0" 606 + source = "registry+https://github.com/rust-lang/crates.io-index" 607 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 608 + 609 + [[package]] 425 610 name = "syn" 426 611 version = "2.0.117" 427 612 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 441 626 "fastrand", 442 627 "getrandom 0.4.2", 443 628 "once_cell", 444 - "rustix", 445 - "windows-sys", 629 + "rustix 1.1.4", 630 + "windows-sys 0.61.2", 446 631 ] 447 632 448 633 [[package]] ··· 484 669 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 485 670 486 671 [[package]] 672 + name = "v4l" 673 + version = "0.14.0" 674 + source = "registry+https://github.com/rust-lang/crates.io-index" 675 + checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" 676 + dependencies = [ 677 + "bitflags 1.3.2", 678 + "libc", 679 + "v4l2-sys-mit", 680 + ] 681 + 682 + [[package]] 683 + name = "v4l2-sys-mit" 684 + version = "0.3.0" 685 + source = "registry+https://github.com/rust-lang/crates.io-index" 686 + checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" 687 + dependencies = [ 688 + "bindgen", 689 + ] 690 + 691 + [[package]] 487 692 name = "wait-timeout" 488 693 version = "0.2.1" 489 694 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 538 743 source = "registry+https://github.com/rust-lang/crates.io-index" 539 744 checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 540 745 dependencies = [ 541 - "bitflags", 746 + "bitflags 2.11.1", 542 747 "hashbrown 0.15.5", 543 748 "indexmap", 544 749 "semver", 545 750 ] 546 751 547 752 [[package]] 753 + name = "which" 754 + version = "4.4.2" 755 + source = "registry+https://github.com/rust-lang/crates.io-index" 756 + checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" 757 + dependencies = [ 758 + "either", 759 + "home", 760 + "once_cell", 761 + "rustix 0.38.44", 762 + ] 763 + 764 + [[package]] 548 765 name = "windows-link" 549 766 version = "0.2.1" 550 767 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 552 769 553 770 [[package]] 554 771 name = "windows-sys" 772 + version = "0.59.0" 773 + source = "registry+https://github.com/rust-lang/crates.io-index" 774 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 775 + dependencies = [ 776 + "windows-targets", 777 + ] 778 + 779 + [[package]] 780 + name = "windows-sys" 555 781 version = "0.61.2" 556 782 source = "registry+https://github.com/rust-lang/crates.io-index" 557 783 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" ··· 560 786 ] 561 787 562 788 [[package]] 789 + name = "windows-targets" 790 + version = "0.52.6" 791 + source = "registry+https://github.com/rust-lang/crates.io-index" 792 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 793 + dependencies = [ 794 + "windows_aarch64_gnullvm", 795 + "windows_aarch64_msvc", 796 + "windows_i686_gnu", 797 + "windows_i686_gnullvm", 798 + "windows_i686_msvc", 799 + "windows_x86_64_gnu", 800 + "windows_x86_64_gnullvm", 801 + "windows_x86_64_msvc", 802 + ] 803 + 804 + [[package]] 805 + name = "windows_aarch64_gnullvm" 806 + version = "0.52.6" 807 + source = "registry+https://github.com/rust-lang/crates.io-index" 808 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 809 + 810 + [[package]] 811 + name = "windows_aarch64_msvc" 812 + version = "0.52.6" 813 + source = "registry+https://github.com/rust-lang/crates.io-index" 814 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 815 + 816 + [[package]] 817 + name = "windows_i686_gnu" 818 + version = "0.52.6" 819 + source = "registry+https://github.com/rust-lang/crates.io-index" 820 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 821 + 822 + [[package]] 823 + name = "windows_i686_gnullvm" 824 + version = "0.52.6" 825 + source = "registry+https://github.com/rust-lang/crates.io-index" 826 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 827 + 828 + [[package]] 829 + name = "windows_i686_msvc" 830 + version = "0.52.6" 831 + source = "registry+https://github.com/rust-lang/crates.io-index" 832 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 833 + 834 + [[package]] 835 + name = "windows_x86_64_gnu" 836 + version = "0.52.6" 837 + source = "registry+https://github.com/rust-lang/crates.io-index" 838 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 839 + 840 + [[package]] 841 + name = "windows_x86_64_gnullvm" 842 + version = "0.52.6" 843 + source = "registry+https://github.com/rust-lang/crates.io-index" 844 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 845 + 846 + [[package]] 847 + name = "windows_x86_64_msvc" 848 + version = "0.52.6" 849 + source = "registry+https://github.com/rust-lang/crates.io-index" 850 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 851 + 852 + [[package]] 563 853 name = "wit-bindgen" 564 854 version = "0.51.0" 565 855 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 623 913 checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 624 914 dependencies = [ 625 915 "anyhow", 626 - "bitflags", 916 + "bitflags 2.11.1", 627 917 "indexmap", 628 918 "log", 629 919 "serde",
+18 -4
Cargo.toml
··· 21 21 # 22 22 # See CONTRIBUTING.md for the build-cache hygiene rationale. 23 23 [workspace.dependencies] 24 - # Internal crates 25 - pareidolia-core = { path = "crates/core" } 26 - pareidolia-daemon = { path = "crates/daemon" } 27 - pareidolia-cli = { path = "crates/cli" } 24 + # Internal crates. `default-features = false` here is load-bearing: it 25 + # prevents pareidolia-daemon's default-on `v4l2` feature from leaking into 26 + # every consumer's build via the dep graph. Consumers that genuinely need 27 + # default features can re-enable them with `default-features = true` in 28 + # their own [dependencies] entry; consumers that don't (CI, integration 29 + # tests, the PAM module) get a clean build without libv4l. 30 + pareidolia-core = { path = "crates/core", default-features = false } 31 + pareidolia-daemon = { path = "crates/daemon", default-features = false } 32 + pareidolia-cli = { path = "crates/cli", default-features = false } 28 33 29 34 # External crates. We deliberately keep this list small; every entry is one 30 35 # more thing the integration-test binary statically links. 31 36 thiserror = "1" 37 + # Linux V4L2 capture. Pareidolia is Linux-only by design (PAM), so a 38 + # Linux-only crate here is fine. Gated behind the `v4l2` feature of 39 + # pareidolia-core; CI opts out with `--no-default-features` so it doesn't 40 + # need libv4l / libclang / kernel headers in its Nixery image. 41 + v4l = "0.14" 42 + # Tiny self-referential struct helper. The v4l crate's Stream<'a> is 43 + # conservatively lifetime-bound to its Device, and we need to own both 44 + # together so the daemon can open + capture + close cleanly. 45 + self_cell = "1" 32 46 33 47 # Test-only deps. These never appear in the binaries we ship; only in the 34 48 # dev-dependencies of crates that own tests.
+12
crates/core/Cargo.toml
··· 14 14 # and is rarely worth the build-cache cost. See CONTRIBUTING.md. 15 15 doctest = false 16 16 17 + [features] 18 + default = [] 19 + # Enables the V4L2-backed FrameSource. The daemon enables it via its own 20 + # default-on `v4l2` feature so users get camera capture out of the box on 21 + # real installs, while `cargo build --no-default-features` on the daemon 22 + # (used by CI) disables it. Library consumers of pareidolia-core that don't 23 + # need capture (cli, pam, integration-tests) don't pay the libv4l / bindgen 24 + # build cost. 25 + v4l2 = ["dep:v4l", "dep:self_cell"] 26 + 17 27 [dependencies] 18 28 thiserror.workspace = true 29 + v4l = { workspace = true, optional = true } 30 + self_cell = { workspace = true, optional = true } 19 31 20 32 [dev-dependencies] 21 33 proptest.workspace = true
+3
crates/core/src/camera.rs
··· 14 14 pub mod convert; 15 15 pub mod mock; 16 16 17 + #[cfg(feature = "v4l2")] 18 + pub mod v4l2; 19 + 17 20 /// What kind of camera produced (or is expected to produce) a frame. 18 21 /// 19 22 /// IR and RGB cameras differ enough — single-channel vs three-channel, very
+407
crates/core/src/camera/v4l2.rs
··· 1 + //! V4L2-backed [`FrameSource`] implementation. 2 + //! 3 + //! Wraps the `v4l` crate to expose Linux V4L2 capture through the same trait 4 + //! the mocks use. Linux-only — but Pareidolia is itself Linux-only, so that 5 + //! constraint is shared by the whole project. 6 + //! 7 + //! # Exclusivity 8 + //! 9 + //! A V4L2 capture device is **exclusive** while buffers are allocated: 10 + //! [`V4l2FrameSource::open`] reserves mmap buffers via `VIDIOC_REQBUFS`, and 11 + //! any other process trying to use the same `/dev/video*` node will get 12 + //! `EBUSY` until this source is dropped. This means Pareidolia *will* block 13 + //! other applications (Zoom, OBS, etc.) from using the same camera while a 14 + //! [`V4l2FrameSource`] is alive. 15 + //! 16 + //! The intended pattern is therefore **open on demand, drop on completion**: 17 + //! the daemon opens a source when handling a PAM auth request, captures the 18 + //! frame(s) it needs, and drops the source so the camera is free again. Drop 19 + //! cleanup is real (no leaked fds) — see the `self_cell`-backed inner type. 20 + //! Holding a [`V4l2FrameSource`] for the lifetime of the daemon is supported 21 + //! but should be a deliberate config decision, not the default. 22 + //! 23 + //! # Public surface 24 + //! 25 + //! - [`enumerate`] walks `/dev/video*` and returns a [`CameraInfo`] for each 26 + //! device we can open. Read-only — calling it does not lock the camera. 27 + //! - [`V4l2FrameSource::open`] opens a specific device and configures it for 28 + //! capture. 29 + //! 30 + //! [`FrameSource`]: super::FrameSource 31 + 32 + use std::path::PathBuf; 33 + use std::time::Instant; 34 + 35 + use self_cell::self_cell; 36 + use v4l::buffer::Type; 37 + use v4l::io::mmap::Stream; 38 + use v4l::io::traits::CaptureStream; 39 + use v4l::video::Capture; 40 + use v4l::{Device, FourCC}; 41 + 42 + use crate::camera::{CameraInfo, CameraKind, Frame, FrameError, FrameSource, PixelFormat}; 43 + 44 + /// Default number of mmap buffers requested from V4L2. Four matches what 45 + /// most UVC drivers expect and is enough to absorb scheduling jitter between 46 + /// capture and consumer without burning RAM. 47 + pub const DEFAULT_BUFFER_COUNT: u32 = 4; 48 + 49 + /// Configuration for opening a V4L2 capture device. 50 + /// 51 + /// `width` / `height` are best-effort: the V4L2 driver matches the closest 52 + /// supported resolution and reports the actual one back, which is then 53 + /// reflected in subsequent [`Frame::width`] / [`Frame::height`] values. 54 + #[derive(Debug, Clone)] 55 + pub struct V4l2Config { 56 + /// Device path, e.g. `/dev/video0`. 57 + pub path: PathBuf, 58 + /// Human-readable name shown to the user. Typically the V4L2 card name 59 + /// from [`enumerate`], but callers may override. 60 + pub name: String, 61 + /// Whether this camera is treated as IR or RGB in the pipeline. 62 + pub kind: CameraKind, 63 + /// Requested capture width. 64 + pub width: u32, 65 + /// Requested capture height. 66 + pub height: u32, 67 + /// Preferred capture format as a V4L2 FourCC (e.g. `*b"YUYV"`). When 68 + /// `None`, the device's current format is left untouched and Pareidolia 69 + /// adapts to whatever the driver hands back. 70 + pub preferred_fourcc: Option<[u8; 4]>, 71 + /// Number of mmap buffers to allocate. Defaults to 72 + /// [`DEFAULT_BUFFER_COUNT`]. 73 + pub buffer_count: u32, 74 + } 75 + 76 + impl V4l2Config { 77 + /// Sensible defaults: 640×480, auto-format, four buffers. 78 + pub fn new(path: impl Into<PathBuf>, name: impl Into<String>, kind: CameraKind) -> Self { 79 + Self { 80 + path: path.into(), 81 + name: name.into(), 82 + kind, 83 + width: 640, 84 + height: 480, 85 + preferred_fourcc: None, 86 + buffer_count: DEFAULT_BUFFER_COUNT, 87 + } 88 + } 89 + } 90 + 91 + // The v4l crate's `Stream<'a>` API is conservatively lifetime-bound to the 92 + // `&Device` passed to `Stream::with_buffers`, even though Stream internally 93 + // holds an `Arc<Handle>` for the file descriptor. To own both together 94 + // (so dropping the source closes the camera cleanly), we use `self_cell` — 95 + // a tiny self-referential helper. Drop order is dependent → owner, which 96 + // matches what V4L2 wants: stream first (unmap buffers, STREAMOFF), then 97 + // device (close fd). 98 + self_cell!( 99 + struct OwnedStream { 100 + owner: Device, 101 + #[covariant] 102 + dependent: Stream, 103 + } 104 + ); 105 + 106 + /// A V4L2 capture device exposed as a [`FrameSource`]. 107 + /// 108 + /// Holds the camera exclusively (see module docs). The daemon's intended 109 + /// pattern is open → capture → drop per auth request, not "open forever." 110 + pub struct V4l2FrameSource { 111 + inner: OwnedStream, 112 + info: CameraInfo, 113 + width: u32, 114 + height: u32, 115 + pixel_format: PixelFormat, 116 + } 117 + 118 + impl V4l2FrameSource { 119 + /// Open the camera at `config.path` and prepare it for capture. 120 + /// 121 + /// Reserves V4L2 mmap buffers as a side effect, which makes the camera 122 + /// **exclusive** to Pareidolia until this source is dropped. See the 123 + /// module-level docs for the rationale and the recommended open-on- 124 + /// demand usage pattern. 125 + /// 126 + /// Returns errors via [`FrameError::Io`] for device-open / ioctl failures, 127 + /// or [`FrameError::Unavailable`] when the negotiated format isn't one 128 + /// Pareidolia knows how to decode. 129 + pub fn open(config: V4l2Config) -> Result<Self, FrameError> { 130 + let device = Device::with_path(&config.path)?; 131 + 132 + // Query the current format, mutate the bits we care about, and let 133 + // V4L2 negotiate. The driver may snap dimensions to a supported 134 + // resolution; the values it returns are what we'll actually receive. 135 + let mut format = Capture::format(&device)?; 136 + format.width = config.width; 137 + format.height = config.height; 138 + if let Some(fcc) = config.preferred_fourcc { 139 + format.fourcc = FourCC::new(&fcc); 140 + } 141 + let format = Capture::set_format(&device, &format)?; 142 + 143 + let pixel_format = pixel_format_from_fourcc(format.fourcc).ok_or_else(|| { 144 + FrameError::Unavailable(format!( 145 + "device {:?} returned unrecognised FourCC {:?}", 146 + config.path, format.fourcc.repr 147 + )) 148 + })?; 149 + 150 + let width = format.width; 151 + let height = format.height; 152 + let buffer_count = config.buffer_count; 153 + 154 + let inner = OwnedStream::try_new(device, |dev| { 155 + Stream::with_buffers(dev, Type::VideoCapture, buffer_count) 156 + })?; 157 + 158 + let info = CameraInfo { 159 + name: config.name, 160 + path: Some(config.path), 161 + kind: config.kind, 162 + }; 163 + 164 + Ok(Self { 165 + inner, 166 + info, 167 + width, 168 + height, 169 + pixel_format, 170 + }) 171 + } 172 + 173 + /// Negotiated pixel format the device is delivering. 174 + pub fn pixel_format(&self) -> PixelFormat { 175 + self.pixel_format 176 + } 177 + } 178 + 179 + impl FrameSource for V4l2FrameSource { 180 + fn capture(&mut self) -> Result<Frame, FrameError> { 181 + // Pre-copy the metadata we need so the closure doesn't try to 182 + // borrow `self` while `inner` is mutably borrowed. 183 + let width = self.width; 184 + let height = self.height; 185 + let pixel_format = self.pixel_format; 186 + let kind = self.info.kind; 187 + 188 + self.inner.with_dependent_mut(|_dev, stream| { 189 + let (data, _meta) = stream.next()?; 190 + Ok(Frame { 191 + width, 192 + height, 193 + format: pixel_format, 194 + // mmap buffer is overwritten on the next next(); copy now. 195 + data: data.to_vec(), 196 + captured_at: Instant::now(), 197 + kind, 198 + }) 199 + }) 200 + } 201 + 202 + fn info(&self) -> &CameraInfo { 203 + &self.info 204 + } 205 + } 206 + 207 + impl std::fmt::Debug for V4l2FrameSource { 208 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 209 + f.debug_struct("V4l2FrameSource") 210 + .field("info", &self.info) 211 + .field("width", &self.width) 212 + .field("height", &self.height) 213 + .field("pixel_format", &self.pixel_format) 214 + .finish_non_exhaustive() 215 + } 216 + } 217 + 218 + /// Translate a V4L2 FourCC into a [`PixelFormat`] Pareidolia knows. 219 + /// 220 + /// Returns `None` for formats outside our supported set; the caller decides 221 + /// whether that's a configuration error or just a "skip this device" signal. 222 + pub fn pixel_format_from_fourcc(fourcc: FourCC) -> Option<PixelFormat> { 223 + match &fourcc.repr { 224 + b"YUYV" => Some(PixelFormat::Yuyv), 225 + b"RGB3" => Some(PixelFormat::Rgb8), 226 + b"GREY" => Some(PixelFormat::Gray8), 227 + b"Y16 " => Some(PixelFormat::Gray16), 228 + b"MJPG" => Some(PixelFormat::Mjpeg), 229 + _ => None, 230 + } 231 + } 232 + 233 + /// Walk `/dev/video*` and return a [`CameraInfo`] for each device that opens 234 + /// successfully and reports its capabilities. 235 + /// 236 + /// Best-effort: devices that fail to open (perms, busy, no driver loaded) 237 + /// are silently skipped. Callers wanting hard errors should open paths 238 + /// directly via [`V4l2FrameSource::open`]. 239 + /// 240 + /// The [`CameraInfo::kind`] field is filled with a heuristic guess: devices 241 + /// that advertise only single-channel formats (`GREY` / `Y16`) are reported 242 + /// as [`CameraKind::Ir`]; anything else as [`CameraKind::Rgb`]. The 243 + /// heuristic is intentionally crude and overridable via configuration. 244 + pub fn enumerate() -> Vec<CameraInfo> { 245 + let mut out = Vec::new(); 246 + let entries = match std::fs::read_dir("/dev") { 247 + Ok(d) => d, 248 + Err(_) => return out, 249 + }; 250 + 251 + for entry in entries.flatten() { 252 + let name = entry.file_name(); 253 + let name_str = name.to_string_lossy(); 254 + if !name_str.starts_with("video") { 255 + continue; 256 + } 257 + let path = entry.path(); 258 + 259 + let Ok(dev) = Device::with_path(&path) else { 260 + continue; 261 + }; 262 + 263 + // Use the device's `card` field as the human-readable name, falling 264 + // back to the device-node basename if querying capabilities fails. 265 + let card_name = dev 266 + .query_caps() 267 + .ok() 268 + .map(|c| c.card.trim_end_matches('\0').trim().to_string()) 269 + .filter(|s| !s.is_empty()) 270 + .unwrap_or_else(|| name_str.to_string()); 271 + 272 + let kind = guess_camera_kind(&dev); 273 + 274 + out.push(CameraInfo { 275 + name: card_name, 276 + path: Some(path), 277 + kind, 278 + }); 279 + } 280 + out 281 + } 282 + 283 + /// Heuristic: if the device's supported format list contains no 284 + /// definitively-colour FourCC, treat it as IR; otherwise RGB. 285 + /// 286 + /// Errs on the side of "RGB" when we can't enumerate at all — the more 287 + /// common kind, and the daemon's config can override either way. 288 + fn guess_camera_kind(dev: &Device) -> CameraKind { 289 + let Ok(formats) = Capture::enum_formats(dev) else { 290 + return CameraKind::Rgb; 291 + }; 292 + let has_color = formats 293 + .iter() 294 + .any(|f| matches!(&f.fourcc.repr, b"YUYV" | b"RGB3" | b"MJPG")); 295 + if has_color { 296 + CameraKind::Rgb 297 + } else { 298 + CameraKind::Ir 299 + } 300 + } 301 + 302 + #[cfg(test)] 303 + mod tests { 304 + use super::*; 305 + 306 + // ----- pure fourcc mapping (no hardware) --------------------------------- 307 + 308 + #[test] 309 + fn fourcc_yuyv_maps_to_yuyv() { 310 + assert_eq!( 311 + pixel_format_from_fourcc(FourCC::new(b"YUYV")), 312 + Some(PixelFormat::Yuyv), 313 + ); 314 + } 315 + 316 + #[test] 317 + fn fourcc_rgb3_maps_to_rgb8() { 318 + assert_eq!( 319 + pixel_format_from_fourcc(FourCC::new(b"RGB3")), 320 + Some(PixelFormat::Rgb8), 321 + ); 322 + } 323 + 324 + #[test] 325 + fn fourcc_grey_maps_to_gray8() { 326 + assert_eq!( 327 + pixel_format_from_fourcc(FourCC::new(b"GREY")), 328 + Some(PixelFormat::Gray8), 329 + ); 330 + } 331 + 332 + #[test] 333 + fn fourcc_y16_space_maps_to_gray16() { 334 + // The trailing space matters: V4L2 distinguishes `Y16 ` (8-bit 335 + // padded to 4 chars) from any other Y-prefixed code. 336 + assert_eq!( 337 + pixel_format_from_fourcc(FourCC::new(b"Y16 ")), 338 + Some(PixelFormat::Gray16), 339 + ); 340 + } 341 + 342 + #[test] 343 + fn fourcc_mjpg_maps_to_mjpeg() { 344 + assert_eq!( 345 + pixel_format_from_fourcc(FourCC::new(b"MJPG")), 346 + Some(PixelFormat::Mjpeg), 347 + ); 348 + } 349 + 350 + #[test] 351 + fn unknown_fourcc_returns_none() { 352 + assert_eq!(pixel_format_from_fourcc(FourCC::new(b"XXXX")), None); 353 + assert_eq!(pixel_format_from_fourcc(FourCC::new(b"NV12")), None); 354 + assert_eq!(pixel_format_from_fourcc(FourCC::new(b"\0\0\0\0")), None); 355 + } 356 + 357 + // ----- enumeration (host-dependent but safe to run without a camera) ----- 358 + 359 + #[test] 360 + fn enumerate_never_panics_on_any_host() { 361 + // The contract is "best effort, no panics". On hosts with no 362 + // /dev/video* (CI, headless servers) this should just return []. 363 + // On hosts with cameras the result is host-dependent; we only 364 + // assert structural properties. 365 + let cams = enumerate(); 366 + for cam in &cams { 367 + assert!( 368 + cam.path.is_some(), 369 + "v4l2-enumerated devices must have a path" 370 + ); 371 + assert!(!cam.name.is_empty(), "name must be populated"); 372 + } 373 + } 374 + 375 + // ----- hardware-dependent (gated by #[ignore]) --------------------------- 376 + 377 + /// Try to open every enumerated camera at its current settings and 378 + /// capture one frame. Hardware-only; CI skips this via #[ignore]. 379 + /// Run with: `cargo nextest run -p pareidolia-core -- --ignored` or 380 + /// `just test-hardware`. 381 + #[test] 382 + #[ignore = "requires a real V4L2 camera"] 383 + fn capture_one_frame_from_each_enumerated_camera() { 384 + let cams = enumerate(); 385 + if cams.is_empty() { 386 + panic!( 387 + "no /dev/video* found; this test should be skipped via #[ignore] \ 388 + when no camera is present" 389 + ); 390 + } 391 + for cam in cams { 392 + let path = cam.path.clone().unwrap(); 393 + let cfg = V4l2Config::new(&path, cam.name.clone(), cam.kind); 394 + let mut src = match V4l2FrameSource::open(cfg) { 395 + Ok(s) => s, 396 + Err(e) => { 397 + eprintln!("skipping {path:?}: {e}"); 398 + continue; 399 + } 400 + }; 401 + let frame = src.capture().expect("capture from real device"); 402 + assert!(frame.width > 0); 403 + assert!(frame.height > 0); 404 + assert!(!frame.data.is_empty()); 405 + } 406 + } 407 + }
+8 -1
crates/daemon/Cargo.toml
··· 18 18 name = "pareidoliad" 19 19 path = "src/main.rs" 20 20 21 + [features] 22 + default = ["v4l2"] 23 + # Re-exports pareidolia-core's `v4l2` feature so building the daemon 24 + # defaults to including the V4L2 backend. CI passes `--no-default-features` 25 + # to skip it (its Nixery image doesn't ship libv4l). 26 + v4l2 = ["pareidolia-core/v4l2"] 27 + 21 28 [dependencies] 22 - pareidolia-core.workspace = true 29 + pareidolia-core = { workspace = true, default-features = false } 23 30 thiserror.workspace = true
+2
crates/integration-tests/Cargo.toml
··· 16 16 path = "tests/integration.rs" 17 17 18 18 [dev-dependencies] 19 + # default-features = false is inherited from [workspace.dependencies] so 20 + # integration tests don't activate daemon's v4l2 feature transitively. 19 21 pareidolia-core.workspace = true 20 22 pareidolia-daemon.workspace = true 21 23 pareidolia-cli.workspace = true
+10 -4
justfile
··· 3 3 # All CI lanes call these recipes by name. Keep them portable so contributors 4 4 # can run the exact same commands locally that CI runs. 5 5 6 + # Cargo flags appended to most build/test commands. Empty locally (so the 7 + # devShell exercises every default-on feature, including v4l2), but CI sets 8 + # the `CARGO_ARGS` env var to `--no-default-features` because its Nixery 9 + # image doesn't ship libv4l / libclang / kernel headers. 10 + cargo_args := env_var_or_default("CARGO_ARGS", "") 11 + 6 12 # Show available recipes. 7 13 default: 8 14 @just --list ··· 15 21 16 22 # Clippy with warnings as errors. 17 23 lint: 18 - cargo clippy --workspace --all-targets -- -D warnings 24 + cargo clippy --workspace --all-targets {{cargo_args}} -- -D warnings 19 25 20 26 # Unit tests only — every crate's in-source `#[cfg(test)]` blocks. 21 27 # Excludes the integration-tests crate to keep this fast. 22 28 test-unit: 23 - cargo nextest run --workspace --lib 29 + cargo nextest run --workspace --lib {{cargo_args}} 24 30 25 31 # Everything the per-push CI job runs. 26 32 ci-fast: fmt-check lint test-unit ··· 29 35 30 36 # The single integration-test binary. 31 37 test-integration: 32 - cargo nextest run -p pareidolia-integration-tests 38 + cargo nextest run -p pareidolia-integration-tests {{cargo_args}} 33 39 34 40 # Build all crates in release mode (catches release-only issues like LTO). 35 41 build-release: 36 - cargo build --workspace --release 42 + cargo build --workspace --release {{cargo_args}} 37 43 38 44 ci-slow: test-integration build-release 39 45