diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0f67e590..96e3f2dd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,6 +12,187 @@ env: RUSTC_WRAPPER: sccache jobs: + pipeline-validation: + name: Pipeline Validation + runs-on: ubuntu-22.04 + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false + + - uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Build UI + working-directory: ./ui + run: | + bun install --frozen-lockfile + bun run build + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.92.0" + + - uses: mozilla-actions/sccache-action@v0.0.9 + + - uses: Swatinem/rust-cache@v2 + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libvpx-dev nasm cmake ninja-build yasm meson ffmpeg + + - name: Build and install SVT-AV1 from source (v4.1.0) + run: | + cd /tmp + git clone --depth 1 --branch v4.1.0 https://gitlab.com/AOMediaCodec/SVT-AV1.git + cd SVT-AV1 + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local + cmake --build build -j"$(nproc)" + sudo cmake --install build + sudo ldconfig + + - name: Build skit + run: cargo build -p streamkit-server --bin skit --features "svt_av1 dav1d_static" + + - name: Start skit server + run: | + SK_SERVER__ADDRESS=127.0.0.1:4545 ./target/debug/skit & + # Wait for the server to be healthy + HEALTHY=0 + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:4545/healthz > /dev/null 2>&1; then + echo "skit is healthy" + HEALTHY=1 + break + fi + sleep 1 + done + if [ "$HEALTHY" -ne 1 ]; then + echo "ERROR: skit did not become healthy within 30s" + exit 1 + fi + + - name: Run pipeline validation tests (SW codecs only) + run: | + cd tests/pipeline-validation + PIPELINE_TEST_URL=http://127.0.0.1:4545 cargo test --test validate + + pipeline-validation-gpu: + name: Pipeline Validation (GPU) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: self-hosted + steps: + - uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Build UI + working-directory: ./ui + run: | + bun install --frozen-lockfile + bun run build + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.92.0" + + - uses: mozilla-actions/sccache-action@v0.0.9 + + - uses: Swatinem/rust-cache@v2 + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libvpx-dev nasm cmake ninja-build yasm meson ffmpeg libopus-dev pkg-config libssl-dev + + - name: Build and install SVT-AV1 from source (v4.1.0) + run: | + cd /tmp + if pkg-config --atleast-version=2.0.0 SvtAv1Enc 2>/dev/null; then + echo "SVT-AV1 already installed, skipping build" + else + rm -rf SVT-AV1 + git clone --depth 1 --branch v4.1.0 https://gitlab.com/AOMediaCodec/SVT-AV1.git + cd SVT-AV1 + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local + cmake --build build -j"$(nproc)" + sudo cmake --install build + sudo ldconfig + fi + + - name: Build skit (with GPU + HW codecs + SVT-AV1 + dav1d) + env: + VPX_LIB_DIR: /usr/lib/x86_64-linux-gnu + VPX_INCLUDE_DIR: /usr/include + VPX_VERSION: "1.13.0" + CUDA_INCLUDE_PATH: /usr/include + # bindgen (used by shiguredo_nvcodec) needs the clang builtin include + # path so it can find stddef.h and other compiler-provided headers. + BINDGEN_EXTRA_CLANG_ARGS: "-I/usr/lib/llvm-18/lib/clang/18/include" + run: | + cargo build -p streamkit-server --bin skit --features "gpu nvcodec vulkan_video svt_av1 dav1d_static" + + - name: Start skit server + run: | + # Use a non-default port to avoid conflicts with any persistent skit + # instance that may be running on the self-hosted runner. + SKIT_PORT=4546 + RUST_LOG=info,streamkit_nodes::codec_utils=debug,streamkit_nodes::video::vulkan_video=debug,streamkit_nodes::core::bytes_output=debug SK_SERVER__ADDRESS=127.0.0.1:${SKIT_PORT} ./target/debug/skit > /tmp/skit-gpu.log 2>&1 & + echo $! > /tmp/skit-gpu.pid + # Wait for the server to be healthy + HEALTHY=0 + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:${SKIT_PORT}/healthz > /dev/null 2>&1; then + echo "skit is healthy on port ${SKIT_PORT}" + HEALTHY=1 + break + fi + sleep 1 + done + if [ "$HEALTHY" -ne 1 ]; then + echo "ERROR: skit did not become healthy within 30s" + exit 1 + fi + + - name: Run pipeline validation tests (all codecs including GPU) + run: | + cd tests/pipeline-validation + PIPELINE_REQUIRE_NODES=1 PIPELINE_TEST_URL=http://127.0.0.1:4546 cargo test --test validate -- --test-threads=1 + + - name: Show skit server logs + if: always() + run: | + if [ -f /tmp/skit-gpu.log ]; then + echo "=== skit server logs (last 500 lines) ===" + tail -500 /tmp/skit-gpu.log + fi + + - name: Stop skit server + if: always() + run: | + if [ -f /tmp/skit-gpu.pid ]; then + kill "$(cat /tmp/skit-gpu.pid)" 2>/dev/null || true + rm -f /tmp/skit-gpu.pid + fi + rm -f /tmp/skit-gpu.log + e2e: name: Playwright E2E runs-on: ubuntu-22.04 diff --git a/.github/workflows/skit.yml b/.github/workflows/skit.yml index da6bdf34..cf902361 100644 --- a/.github/workflows/skit.yml +++ b/.github/workflows/skit.yml @@ -120,7 +120,9 @@ jobs: - uses: actions/checkout@v5 - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libvpx-dev nasm cmake pkg-config libopus-dev + run: | + sudo apt-get update + sudo apt-get install -y libvpx-dev nasm cmake pkg-config libopus-dev libva-dev libgbm-dev - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master @@ -140,8 +142,16 @@ jobs: VPX_LIB_DIR: /usr/lib/x86_64-linux-gnu VPX_INCLUDE_DIR: /usr/include VPX_VERSION: "1.13.0" + # Ubuntu's nvidia-cuda-toolkit installs headers to /usr/include, not + # /usr/local/cuda/include. Tell shiguredo_nvcodec where to find them. + CUDA_INCLUDE_PATH: /usr/include + # bindgen (used by shiguredo_nvcodec) needs the clang builtin include + # path so it can find stddef.h and other compiler-provided headers. + BINDGEN_EXTRA_CLANG_ARGS: "-I/usr/lib/llvm-18/lib/clang/18/include" run: | cargo test --locked -p streamkit-nodes --features gpu + cargo test --locked -p streamkit-nodes --features nvcodec + cargo test --locked -p streamkit-nodes --features vaapi cargo test --locked -p streamkit-engine --features gpu build: diff --git a/Cargo.lock b/Cargo.lock index a0b10d8a..3af95142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,7 +183,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -246,7 +246,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -258,7 +258,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -297,7 +297,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -308,7 +308,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -343,7 +343,7 @@ checksum = "49c98dba06b920588de7d63f6acc23f1e6a9fade5fd6198e564506334fb5a4f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -573,6 +573,46 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -609,6 +649,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + [[package]] name = "bitstream-io" version = "4.9.0" @@ -686,7 +732,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -818,12 +864,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -882,6 +943,17 @@ dependencies = [ "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 0.8.9", +] + [[package]] name = "clap" version = "4.6.0" @@ -913,7 +985,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -955,6 +1027,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ + "serde", + "termcolor", "unicode-width", ] @@ -1334,6 +1408,40 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "cros-codecs" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80f7441b4f31c17b6b6b7f57f6c202944aad11d0ab23739a9ff88d8d34dec621" +dependencies = [ + "anyhow", + "byteorder", + "crc32fast", + "cros-libva", + "drm", + "drm-fourcc", + "gbm", + "gbm-sys", + "log", + "nix 0.28.0", + "thiserror 1.0.69", + "zerocopy 0.8.47", +] + +[[package]] +name = "cros-libva" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902c9726e953b678595456bd38f95f31aaf1947c56dd9f4a2290f3f1eca4d228" +dependencies = [ + "bindgen 0.70.1", + "bitflags 2.11.0", + "log", + "pkg-config", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1404,7 +1512,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -1415,7 +1523,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1463,6 +1571,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1482,7 +1601,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -1553,7 +1672,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", ] [[package]] @@ -1574,6 +1702,45 @@ dependencies = [ "litrs", ] +[[package]] +name = "drm" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53" +dependencies = [ + "drm-sys", + "rustix 0.38.44", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd39dde40b6e196c2e8763f23d119ddb1a8714534bf7d77fa97a65b0feda3986" +dependencies = [ + "libc", + "linux-raw-sys 0.6.5", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1654,7 +1821,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1869,6 +2036,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "four-cc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629" + [[package]] name = "fs-err" version = "3.3.0" @@ -1952,7 +2125,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1998,6 +2171,28 @@ dependencies = [ "serde_json", ] +[[package]] +name = "gbm" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf55ba6dd53ad0ac115046ff999c5324c283444ee6e0be82454c4e8eb2f36a" +dependencies = [ + "bitflags 2.11.0", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", +] + +[[package]] +name = "gbm-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cc2f64de9fa707b5c6b2d2f10d7a7e49e845018a9f5685891eb40d3bab2538" +dependencies = [ + "libc", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2077,6 +2272,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.3" @@ -2095,6 +2301,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -2148,6 +2375,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "h264-reader" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "036a78b2620d92f0ec57690bc792b3bb87348632ee5225302ba2e66a48021c6c" +dependencies = [ + "bitstream-io 2.6.0", + "hex-slice", + "log", + "memchr", + "rfc6381-codec", +] + [[package]] name = "half" version = "2.7.1" @@ -2249,6 +2489,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-slice" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5491a308e0214554f07a81d8944abe45f552871c12e3c3c6e7e5d354039a6c4c" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -2658,7 +2904,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2803,7 +3049,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2878,6 +3124,23 @@ dependencies = [ "signature", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kurbo" version = "0.13.0" @@ -2973,6 +3236,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3210,6 +3479,21 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mp4ra-rust" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdbc3d3867085d66ac6270482e66f3dd2c5a18451a3dc9ad7269e94844a536b7" +dependencies = [ + "four-cc", +] + +[[package]] +name = "mpeg4-audio-const" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a1fe2275b68991faded2c80aa4a33dba398b77d276038b8f50701a22e55918" + [[package]] name = "multer" version = "3.1.0" @@ -3243,7 +3527,7 @@ dependencies = [ "bit-set", "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "codespan-reporting", "half", "hashbrown 0.16.1", @@ -3286,6 +3570,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3312,6 +3605,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -3320,7 +3625,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -3420,7 +3725,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3493,7 +3798,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3695,7 +4000,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3877,7 +4182,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3919,7 +4224,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4069,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -4107,7 +4412,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "version_check", "yansi", ] @@ -4128,7 +4433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4168,7 +4473,7 @@ dependencies = [ "prost 0.12.6", "prost-types 0.12.6", "regex", - "syn", + "syn 2.0.117", "tempfile", ] @@ -4182,7 +4487,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4195,7 +4500,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4236,7 +4541,7 @@ checksum = "823a9d8da391be21a5f4d5e11c39d15f45b011076c6825fc2323f7e4753f09ce" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4305,7 +4610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4348,7 +4653,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -4511,7 +4816,7 @@ dependencies = [ "arrayvec", "av-scenechange", "av1-grain", - "bitstream-io", + "bitstream-io 4.9.0", "built", "cc", "cfg-if", @@ -4650,7 +4955,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4834,6 +5139,16 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfc6381-codec" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed54c20f5c3ec82eab6d998b313dc75ec5d5650d4f57675e61d72489040297fd" +dependencies = [ + "mp4ra-rust", + "mpeg4-audio-const", +] + [[package]] name = "rgb" version = "0.8.53" @@ -4932,7 +5247,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn", + "syn 2.0.117", "walkdir", ] @@ -5234,7 +5549,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -5323,7 +5638,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5334,7 +5649,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5419,7 +5734,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5483,6 +5798,17 @@ version = "2026.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b135058874815f8f13edae644ceedb659f7238fe4a9e2b1bdceecc72dc659b35" +[[package]] +name = "shiguredo_nvcodec" +version = "2025.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abdb7e695a3fe6f37ea08a6366c6848ea1d4491dafbf793fe5d2691928087c8" +dependencies = [ + "bindgen 0.72.1", + "libloading 0.8.9", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5554,6 +5880,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -5705,6 +6040,7 @@ dependencies = [ "bytes", "cc", "cmake", + "cros-codecs", "env-libvpx-sys", "fontdue", "futures", @@ -5734,6 +6070,7 @@ dependencies = [ "serde-saphyr", "serde_json", "shiguredo_mp4", + "shiguredo_nvcodec", "smallvec", "streamkit-core", "symphonia", @@ -5747,6 +6084,7 @@ dependencies = [ "ts-rs", "url", "uuid", + "vk-video", "webm", "wgpu", "wildmatch", @@ -5923,7 +6261,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -6074,6 +6412,17 @@ dependencies = [ "symphonia-metadata", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -6102,7 +6451,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6180,7 +6529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -6221,7 +6570,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6232,7 +6581,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6414,7 +6763,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6764,7 +7113,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6870,7 +7219,7 @@ checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "termcolor", ] @@ -7087,7 +7436,38 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "vk-mem" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cb12b79bcec57a3334d0284f1364c1846f378bb47df9779c6dbfcfc245c9404" +dependencies = [ + "ash", + "bitflags 2.11.0", + "cc", +] + +[[package]] +name = "vk-video" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6accac84fee2e209165c93dfc9e44ae37391b4e0b812aba92660bfc0ca77c440" +dependencies = [ + "ash", + "bytemuck", + "bytes", + "cfg_aliases 0.2.1", + "derivative", + "h264-reader", + "memchr", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "tracing", + "vk-mem", + "wgpu", ] [[package]] @@ -7179,7 +7559,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -7417,7 +7797,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", "wit-parser", @@ -7526,7 +7906,7 @@ checksum = "cbfbbfdb0cfd638145b0de4d3e309901ccc4e29965a33ca1eb18ab6f37057350" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7633,6 +8013,18 @@ dependencies = [ "wast 245.0.1", ] +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-async" version = "0.1.3" @@ -7750,15 +8142,21 @@ dependencies = [ "bitflags 2.11.0", "bytemuck", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", + "js-sys", "log", + "naga", + "parking_lot", "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -7775,7 +8173,7 @@ dependencies = [ "bit-vec", "bitflags 2.11.0", "bytemuck", - "cfg_aliases", + "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", "indexmap 2.13.0", @@ -7790,6 +8188,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -7805,6 +8204,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.0" @@ -7828,14 +8236,19 @@ dependencies = [ "block2", "bytemuck", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", + "glow", + "glutin_wgl_sys", "gpu-allocator", "gpu-descriptor", "hashbrown 0.16.1", + "js-sys", + "khronos-egl", "libc", "libloading 0.8.9", "log", "naga", + "ndk-sys", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -7853,6 +8266,9 @@ dependencies = [ "renderdoc-sys", "smallvec", "thiserror 2.0.18", + "wasm-bindgen", + "wayland-sys", + "web-sys", "wgpu-naga-bridge", "wgpu-types", "windows", @@ -7877,8 +8293,10 @@ checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6" dependencies = [ "bitflags 2.11.0", "bytemuck", + "js-sys", "log", "raw-window-handle", + "web-sys", ] [[package]] @@ -7914,7 +8332,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasmtime-environ", "witx", ] @@ -7927,7 +8345,7 @@ checksum = "b8f625d05adeddad85c8d5fbcd765a8ecf1b22260840a0a193125dc4ab06ac9d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wiggle-generate", ] @@ -8049,7 +8467,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8060,7 +8478,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8415,7 +8833,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8431,7 +8849,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8519,6 +8937,12 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xmlwriter" version = "0.1.0" @@ -8565,7 +8989,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -8596,7 +9020,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8607,7 +9031,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8627,7 +9051,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -8667,7 +9091,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f0964675..54c26855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,3 +110,4 @@ cast_sign_loss = "warn" module_name_repetitions = "allow" must_use_candidate = "allow" doc_markdown = "allow" + diff --git a/apps/skit/Cargo.toml b/apps/skit/Cargo.toml index 81efe5c0..98b540c1 100644 --- a/apps/skit/Cargo.toml +++ b/apps/skit/Cargo.toml @@ -152,6 +152,9 @@ svt_av1 = ["streamkit-nodes/svt_av1"] svt_av1_static = ["streamkit-nodes/svt_av1_static"] dav1d = ["streamkit-nodes/dav1d"] dav1d_static = ["streamkit-nodes/dav1d_static"] +vulkan_video = ["streamkit-nodes/vulkan_video"] +vaapi = ["streamkit-nodes/vaapi"] +nvcodec = ["streamkit-nodes/nvcodec"] [dev-dependencies] tokio-test = "0.4.5" diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index e88d9339..e94849a2 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -395,8 +395,22 @@ impl NodeContext { pub async fn recv_with_cancellation(&self, rx: &mut mpsc::Receiver) -> Option { if let Some(token) = &self.cancellation_token { tokio::select! { - () = token.cancelled() => None, - packet = rx.recv() => packet, + () = token.cancelled() => { + tracing::debug!( + node = %self.output_sender.node_name(), + "recv_with_cancellation: cancelled by token" + ); + None + } + packet = rx.recv() => { + if packet.is_none() { + tracing::debug!( + node = %self.output_sender.node_name(), + "recv_with_cancellation: input channel closed" + ); + } + packet + } } } else { rx.recv().await diff --git a/crates/engine/src/graph_builder.rs b/crates/engine/src/graph_builder.rs index 9d3709ea..60f86d97 100644 --- a/crates/engine/src/graph_builder.rs +++ b/crates/engine/src/graph_builder.rs @@ -401,6 +401,20 @@ pub async fn wire_and_spawn_graph( let result = node.run(context).await; let duration = start_time.elapsed(); + match &result { + Ok(()) => tracing::debug!( + node_id = %name, + ?duration, + "node task completed successfully", + ), + Err(e) => tracing::warn!( + node_id = %name, + ?duration, + error = %e, + "node task failed", + ), + } + let meter = global::meter("skit_engine"); let histogram = meter .f64_histogram("node.execution.duration") diff --git a/crates/nodes/Cargo.toml b/crates/nodes/Cargo.toml index 62a2e06c..3aca760f 100644 --- a/crates/nodes/Cargo.toml +++ b/crates/nodes/Cargo.toml @@ -106,6 +106,11 @@ wgpu = { version = "29", optional = true, default-features = false, features = [ pollster = { version = "0.4", optional = true } bytemuck = { version = "1.22", optional = true, features = ["derive"] } +# HW-accelerated video codecs (optional, behind respective features) +vk-video = { version = "0.3", optional = true } # vulkan_video feature — Vulkan Video H.264 HW codec +cros-codecs = { version = "0.0.6", optional = true, features = ["vaapi"] } # vaapi feature — requires libva-dev system package +shiguredo_nvcodec = { version = "2025.2", optional = true } + futures-util = "0.3" [features] @@ -177,6 +182,15 @@ object_store = ["dep:opendal", "dep:schemars"] codegen = ["dep:ts-rs"] video = ["vp9", "av1", "openh264", "colorbars", "compositor"] +# HW-accelerated video codecs — not in `default`; each requires vendor-specific +# system libraries or drivers at runtime. +# vulkan_video: H.264 encode/decode via Vulkan Video (vk-video crate). Cross-vendor (Intel/NVIDIA/AMD). +vulkan_video = ["dep:schemars", "dep:vk-video", "dep:serde_json"] +# vaapi: AV1 encode/decode via VA-API (cros-codecs crate). Primarily Intel, also AMD. +vaapi = ["dep:schemars", "dep:cros-codecs", "dep:serde_json"] +# nvcodec: AV1 encode/decode via NVENC/NVDEC (shiguredo_nvcodec crate). NVIDIA only. +nvcodec = ["dep:schemars", "dep:shiguredo_nvcodec", "dep:serde_json"] + [[bin]] name = "generate-compositor-types" path = "src/bin/generate_compositor_types.rs" diff --git a/crates/nodes/src/codec_utils.rs b/crates/nodes/src/codec_utils.rs index 7ed0cc8d..6bb65653 100644 --- a/crates/nodes/src/codec_utils.rs +++ b/crates/nodes/src/codec_utils.rs @@ -16,6 +16,72 @@ use streamkit_core::types::Packet; use streamkit_core::NodeContext; use tokio::sync::mpsc; +/// Drain remaining codec results while concurrently awaiting the codec task. +/// +/// We must keep reading from `result_rx` while the codec task is still +/// running because the codec task uses `blocking_send()` on a bounded +/// channel (capacity 32). If we awaited the codec task first (without +/// draining), the channel would fill up and the codec task would block +/// forever — a deadlock. +/// +/// Once the codec task finishes, `result_tx` is dropped, so +/// `result_rx.recv()` will eventually return `None` and we exit the +/// drain loop naturally with all results forwarded. +async fn drain_codec_results Packet + Send + Sync>( + result_rx: &mut mpsc::Receiver>, + mut codec_task: tokio::task::JoinHandle<()>, + context: &mut NodeContext, + counter: &opentelemetry::metrics::Counter, + stats: &mut NodeStatsTracker, + to_packet: &F, + label: &str, +) { + let mut codec_done = false; + let mut drained = 0u64; + loop { + tokio::select! { + biased; + // Drain results first (biased) to keep the channel + // flowing and unblock the codec task's blocking_send(). + maybe_result = result_rx.recv() => { + match maybe_result { + Some(Ok(item)) => { + drained += 1; + counter.add(1, &[KeyValue::new("status", "ok")]); + stats.received(); + if context.output_sender.send("out", to_packet(item)).await.is_err() { + tracing::debug!("Output channel closed during drain"); + break; + } + stats.sent(); + stats.maybe_send(); + } + Some(Err(err)) => { + counter.add(1, &[KeyValue::new("status", "error")]); + stats.received(); + stats.errored(); + stats.maybe_send(); + tracing::warn!("{label} codec error: {err}"); + } + None => break, // channel closed — codec task dropped result_tx + } + } + res = &mut codec_task, if !codec_done => { + codec_done = true; + if let Err(e) = res { + if e.is_panic() { + tracing::error!("{label} codec task panicked: {e:?}"); + break; + } + } + // Codec task finished — result_tx is dropped. + // Continue draining any buffered results until recv() returns None. + } + } + } + tracing::debug!("{label} drain complete: forwarded {drained} result(s)"); +} + /// Shared select-loop that forwards codec results to the output sender. /// /// Handles three concurrent events: @@ -33,7 +99,7 @@ pub async fn codec_forward_loop( codec_tx: mpsc::Sender, counter: &opentelemetry::metrics::Counter, stats: &mut NodeStatsTracker, - to_packet: impl Fn(T) -> Packet, + to_packet: impl Fn(T) -> Packet + Send + Sync, label: &str, ) { /// Forwards a single successful codec result to the output sender. @@ -69,6 +135,8 @@ pub async fn codec_forward_loop( tracing::warn!("{label} codec error: {err}"); } + let mut drain_pending = false; + loop { tokio::select! { maybe_result = result_rx.recv() => { @@ -85,10 +153,6 @@ pub async fn codec_forward_loop( Some(control_msg) = context.control_rx.recv() => { if matches!(control_msg, streamkit_core::control::NodeControlMessage::Shutdown) { tracing::info!("{label} received shutdown signal"); - // NOTE: Dropping codec_tx first signals the codec thread to - // exit/flush, then aborting ensures it doesn't linger. - // Because we break out here, flushed results are never sent - // downstream. Data loss on explicit shutdown is acceptable. input_task.abort(); drop(codec_tx); codec_task.abort(); @@ -96,22 +160,24 @@ pub async fn codec_forward_loop( } } _ = &mut *input_task => { + tracing::debug!("{label} input task completed, starting drain"); drop(codec_tx); - while let Some(maybe_result) = result_rx.recv().await { - match maybe_result { - Ok(item) => { - if forward_one(to_packet(item), context, counter, stats).await { - break; - } - } - Err(err) => handle_error(&err, counter, stats, label), - } - } + drain_pending = true; break; } } } - codec_task.abort(); - let _ = codec_task.await; + if drain_pending { + tracing::debug!("{label} waiting for codec task to finish before drain"); + drain_codec_results(result_rx, codec_task, context, counter, stats, &to_packet, label) + .await; + } else { + // Abort before awaiting: the codec task may be blocked on + // `result_tx.blocking_send()` with a full channel since nobody + // is draining `result_rx` anymore (output closed or channel + // dropped). Without the abort this would deadlock. + codec_task.abort(); + let _ = codec_task.await; + } } diff --git a/crates/nodes/src/containers/mp4.rs b/crates/nodes/src/containers/mp4.rs index 3e6c6701..80af6e3b 100644 --- a/crates/nodes/src/containers/mp4.rs +++ b/crates/nodes/src/containers/mp4.rs @@ -89,6 +89,14 @@ fn build_avc1_sample_entry(width: u16, height: u16, codec_private: Option<&[u8]> parse_avcc_codec_private, ); + // For High profile and above (anything other than Baseline 66, + // Main 77, Extended 88), the avcC box requires chroma_format and + // bit-depth fields. Default to 4:2:0 / 8-bit which matches NV12. + let needs_chroma_fields = !matches!(profile, 66 | 77 | 88); + let chroma_format = if needs_chroma_fields { Some(Uint::new(1)) } else { None }; + let bit_depth_luma_minus8 = if needs_chroma_fields { Some(Uint::new(0)) } else { None }; + let bit_depth_chroma_minus8 = if needs_chroma_fields { Some(Uint::new(0)) } else { None }; + SampleEntry::Avc1(Avc1Box { visual: VisualSampleEntryFields { data_reference_index: VisualSampleEntryFields::DEFAULT_DATA_REFERENCE_INDEX, @@ -107,9 +115,9 @@ fn build_avc1_sample_entry(width: u16, height: u16, codec_private: Option<&[u8]> length_size_minus_one: Uint::new(3), sps_list, pps_list, - chroma_format: None, - bit_depth_luma_minus8: None, - bit_depth_chroma_minus8: None, + chroma_format, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, sps_ext_list: vec![], }, unknown_boxes: vec![], @@ -296,6 +304,15 @@ fn rebuild_avc1_entry_from_params( .filter(|sps| sps.len() >= 4) .map_or((66, 0, 31), |sps| (sps[1], sps[2], sps[3])); + // For High profile and above (anything other than Baseline 66, + // Main 77, Extended 88), the avcC box requires chroma_format and + // bit-depth fields. Default to 4:2:0 / 8-bit which matches NV12 + // input — the standard output of HW encoders. + let needs_chroma_fields = !matches!(profile, 66 | 77 | 88); + let chroma_format = if needs_chroma_fields { Some(Uint::new(1)) } else { None }; + let bit_depth_luma_minus8 = if needs_chroma_fields { Some(Uint::new(0)) } else { None }; + let bit_depth_chroma_minus8 = if needs_chroma_fields { Some(Uint::new(0)) } else { None }; + SampleEntry::Avc1(Avc1Box { visual: VisualSampleEntryFields { data_reference_index: VisualSampleEntryFields::DEFAULT_DATA_REFERENCE_INDEX, @@ -314,9 +331,9 @@ fn rebuild_avc1_entry_from_params( length_size_minus_one: Uint::new(3), sps_list, pps_list, - chroma_format: None, - bit_depth_luma_minus8: None, - bit_depth_chroma_minus8: None, + chroma_format, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, sps_ext_list: vec![], }, unknown_boxes: vec![], @@ -1098,6 +1115,116 @@ fn partition_samples_by_track( // Stream (fMP4) mode // --------------------------------------------------------------------------- +/// Accumulate a single video frame into the pending segment state. +/// +/// Handles Annex B → AVCC conversion for H.264, sample-entry tracking, +/// and duration calculation. +#[allow(clippy::too_many_arguments)] +fn accumulate_video_sample( + data: &Bytes, + metadata: Option<&PacketMetadata>, + is_keyframe: bool, + is_h264: bool, + config: &Mp4MuxerConfig, + video_timescale: NonZeroU32, + video_sample_entry: &mut SampleEntry, + seg: &mut Fmp4SegmentState, +) { + // Convert Annex B → AVCC for H.264 streams so the mdat + // contains length-prefixed NAL units matching the avc1 box. + let data = if is_h264 { + let conv = convert_annexb_to_avcc(data); + // On the first keyframe, update the sample entry with + // real SPS/PPS so the init segment (moov) describes the + // actual stream parameters instead of placeholders. + if !conv.sps_list.is_empty() && !seg.video_sample_entry_sent { + *video_sample_entry = rebuild_avc1_entry_from_params( + config.video_width, + config.video_height, + conv.sps_list, + conv.pps_list, + ); + } + conv.data + } else { + data.clone() + }; + + let duration_us = + metadata.as_ref().and_then(|m| m.duration_us).unwrap_or(DEFAULT_VIDEO_FRAME_DURATION_US); + let duration_ticks = us_to_ticks(duration_us, video_timescale.get()); + + let data_size = data.len(); + let entry = if seg.video_sample_entry_sent { + None + } else { + seg.video_sample_entry_sent = true; + Some(video_sample_entry.clone()) + }; + seg.pending_samples.push(Sample { + track_kind: TrackKind::Video, + timescale: video_timescale, + sample_entry: entry, + duration: duration_ticks, + keyframe: is_keyframe, + composition_time_offset: None, + data_offset: 0, // placeholder; partition_samples_by_track recomputes + data_size, + }); + seg.pending_payloads.push(data); +} + +/// Accumulate a single audio frame into the pending segment state. +fn accumulate_audio_sample( + data: Bytes, + metadata: Option<&PacketMetadata>, + audio_codec: AudioCodec, + audio_timescale: NonZeroU32, + audio_sample_entry: &SampleEntry, + seg: &mut Fmp4SegmentState, +) { + let duration_us = metadata + .and_then(|m| m.duration_us) + .unwrap_or_else(|| audio_codec.default_frame_duration_us()); + let duration_ticks = us_to_ticks(duration_us, audio_timescale.get()); + + let data_size = data.len(); + let entry = if seg.audio_sample_entry_sent { + None + } else { + seg.audio_sample_entry_sent = true; + Some(audio_sample_entry.clone()) + }; + seg.pending_samples.push(Sample { + track_kind: TrackKind::Audio, + timescale: audio_timescale, + sample_entry: entry, + duration: duration_ticks, + keyframe: true, // audio frames are always keyframes + composition_time_offset: None, + data_offset: 0, // placeholder; partition_samples_by_track recomputes + data_size, + }); + seg.pending_payloads.push(data); +} + +/// Check the keyframe gate for video frames. +/// +/// Returns `true` if the frame should be processed, `false` if it should be +/// skipped (still waiting for the first keyframe). +fn check_video_keyframe_gate(is_keyframe: bool, video_keyframe_seen: &mut bool) -> bool { + if *video_keyframe_seen { + return true; + } + if !is_keyframe { + tracing::debug!("Mp4MuxerNode: skipping non-keyframe video (waiting for first keyframe)"); + return false; + } + tracing::debug!("Mp4MuxerNode: first video keyframe received"); + *video_keyframe_seen = true; + true +} + /// Run the muxer in fragmented MP4 (fMP4) streaming mode. /// /// Each batch of samples is turned into a media segment (moof + mdat) and @@ -1163,91 +1290,34 @@ async fn run_stream_mode( }, MuxFrame::Video(data, metadata) => { let is_keyframe = metadata.as_ref().and_then(|m| m.keyframe).unwrap_or(false); - - if !video_keyframe_seen { - if is_keyframe { - video_keyframe_seen = true; - } else { - continue; - } + if !check_video_keyframe_gate(is_keyframe, &mut video_keyframe_seen) { + continue; } packet_count += 1; stats_tracker.received(); - - // Convert Annex B → AVCC for H.264 streams so the mdat - // contains length-prefixed NAL units matching the avc1 box. - let data = if is_h264 { - let conv = convert_annexb_to_avcc(&data); - // On the first keyframe, update the sample entry with - // real SPS/PPS so the init segment (moov) describes the - // actual stream parameters instead of placeholders. - if !conv.sps_list.is_empty() && !seg.video_sample_entry_sent { - video_sample_entry = rebuild_avc1_entry_from_params( - session.config.video_width, - session.config.video_height, - conv.sps_list, - conv.pps_list, - ); - } - conv.data - } else { - data - }; - - let duration_us = metadata - .as_ref() - .and_then(|m| m.duration_us) - .unwrap_or(DEFAULT_VIDEO_FRAME_DURATION_US); - let duration_ticks = us_to_ticks(duration_us, video_timescale.get()); - - let data_size = data.len(); - let entry = if seg.video_sample_entry_sent { - None - } else { - seg.video_sample_entry_sent = true; - Some(video_sample_entry.clone()) - }; - seg.pending_samples.push(Sample { - track_kind: TrackKind::Video, - timescale: video_timescale, - sample_entry: entry, - duration: duration_ticks, - keyframe: is_keyframe, - composition_time_offset: None, - data_offset: 0, // placeholder; partition_samples_by_track recomputes - data_size, - }); - seg.pending_payloads.push(data); + accumulate_video_sample( + &data, + metadata.as_ref(), + is_keyframe, + is_h264, + session.config, + video_timescale, + &mut video_sample_entry, + &mut seg, + ); }, MuxFrame::Audio(data, metadata) => { packet_count += 1; stats_tracker.received(); - - let duration_us = metadata - .as_ref() - .and_then(|m| m.duration_us) - .unwrap_or_else(|| session.audio_codec.default_frame_duration_us()); - let duration_ticks = us_to_ticks(duration_us, audio_timescale.get()); - - let data_size = data.len(); - let entry = if seg.audio_sample_entry_sent { - None - } else { - seg.audio_sample_entry_sent = true; - Some(audio_sample_entry.clone()) - }; - seg.pending_samples.push(Sample { - track_kind: TrackKind::Audio, - timescale: audio_timescale, - sample_entry: entry, - duration: duration_ticks, - keyframe: true, // audio frames are always keyframes - composition_time_offset: None, - data_offset: 0, // placeholder; partition_samples_by_track recomputes - data_size, - }); - seg.pending_payloads.push(data); + accumulate_audio_sample( + data, + metadata.as_ref(), + session.audio_codec, + audio_timescale, + &audio_sample_entry, + &mut seg, + ); }, } diff --git a/crates/nodes/src/video/mod.rs b/crates/nodes/src/video/mod.rs index 3e855914..bde90a5a 100644 --- a/crates/nodes/src/video/mod.rs +++ b/crates/nodes/src/video/mod.rs @@ -70,6 +70,34 @@ pub const AV1_CONTENT_TYPE: &str = "video/av1"; /// MIME-style content type for H.264-encoded video packets. pub const H264_CONTENT_TYPE: &str = "video/h264"; +// ── Hardware acceleration mode ─────────────────────────────────────────────── +// +// Shared across all HW-accelerated codec modules (Vulkan Video, VA-API, NVENC). + +/// Hardware acceleration mode for GPU codec nodes. +/// +/// Mirrors the compositor's `gpu_mode` pattern: auto-detect by default, +/// with explicit force options for testing and deployment. +#[cfg(any(feature = "vulkan_video", feature = "vaapi", feature = "nvcodec"))] +#[derive( + Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum HwAccelMode { + /// Auto-detect: attempt hardware acceleration. + /// + /// For HW-only nodes (Vulkan Video, VA-API, NVENC/NVDEC) this behaves + /// identically to `ForceHw` — the node will fail if the required + /// hardware is unavailable. CPU fallback is achieved by selecting a + /// different (software) node at the pipeline level. + #[default] + Auto, + /// Force HW acceleration — fail if unavailable. + ForceHw, + /// Force CPU path — ignore available HW. + ForceCpu, +} + /// Parse a pixel format string into a [`PixelFormat`]. /// /// Accepts `"i420"`, `"nv12"`, `"rgba8"`, or `"rgba"` (case-insensitive). @@ -100,11 +128,80 @@ pub mod pixel_ops; #[cfg(feature = "compositor")] pub mod pixel_convert; -#[cfg(any(feature = "vp9", feature = "av1", feature = "svt_av1", feature = "openh264"))] +#[cfg(any( + feature = "vp9", + feature = "av1", + feature = "svt_av1", + feature = "openh264", + feature = "nvcodec", + feature = "vaapi" +))] pub(crate) mod encoder_trait; +// ── HW-accelerated codec modules ───────────────────────────────────────────── + +#[cfg(feature = "vulkan_video")] +pub mod vulkan_video; + +#[cfg(feature = "vaapi")] +pub mod vaapi_av1; + +#[cfg(feature = "vaapi")] +pub mod vaapi_h264; + +#[cfg(feature = "vaapi")] +mod vaapi_h264_enc; + +#[cfg(feature = "nvcodec")] +pub mod nv_av1; + // ── Shared I420→NV12 conversion helpers ────────────────────────────────────── -// + +/// Convert an I420 [`VideoFrame`] into a contiguous NV12 byte buffer. +/// +/// Copies the Y plane as-is, then interleaves the U and V planes into +/// a single UV plane. Used by HW encoders that need NV12 input from +/// an I420 source frame. +#[cfg(any(feature = "vulkan_video", feature = "nvcodec"))] +pub(super) fn i420_frame_to_nv12_buffer(frame: &streamkit_core::types::VideoFrame) -> Vec { + let width = frame.width as usize; + let height = frame.height as usize; + let layout = frame.layout(); + let planes = layout.planes(); + let data = frame.data.as_slice(); + + let chroma_w = width.div_ceil(2); + let chroma_h = height.div_ceil(2); + let uv_row_bytes = chroma_w * 2; + let nv12_size = width * height + uv_row_bytes * chroma_h; + let mut nv12 = vec![0u8; nv12_size]; + + // Copy Y plane. + let y_plane = &planes[0]; + for row in 0..height { + let src_start = y_plane.offset + row * y_plane.stride; + let dst_start = row * width; + nv12[dst_start..dst_start + width].copy_from_slice(&data[src_start..src_start + width]); + } + + // Interleave U + V into NV12 UV plane. + let u_plane = &planes[1]; + let v_plane = &planes[2]; + let uv_offset = width * height; + + for row in 0..chroma_h { + let u_src_start = u_plane.offset + row * u_plane.stride; + let v_src_start = v_plane.offset + row * v_plane.stride; + let dst_start = uv_offset + row * uv_row_bytes; + for col in 0..chroma_w { + nv12[dst_start + col * 2] = data[u_src_start + col]; + nv12[dst_start + col * 2 + 1] = data[v_src_start + col]; + } + } + + nv12 +} + // Used by both the rav1d decoder (av1.rs) and the C dav1d decoder (dav1d.rs). /// Raw plane pointers + strides for an I420 picture, abstracting over the @@ -590,4 +687,16 @@ pub fn register_video_nodes(registry: &mut NodeRegistry, constraints: &GlobalNod #[cfg(feature = "dav1d")] dav1d::register_dav1d_nodes(registry); + + #[cfg(feature = "vulkan_video")] + vulkan_video::register_vulkan_video_nodes(registry); + + #[cfg(feature = "vaapi")] + vaapi_av1::register_vaapi_av1_nodes(registry); + + #[cfg(feature = "vaapi")] + vaapi_h264::register_vaapi_h264_nodes(registry); + + #[cfg(feature = "nvcodec")] + nv_av1::register_nv_av1_nodes(registry); } diff --git a/crates/nodes/src/video/nv_av1.rs b/crates/nodes/src/video/nv_av1.rs new file mode 100644 index 00000000..84d7eb10 --- /dev/null +++ b/crates/nodes/src/video/nv_av1.rs @@ -0,0 +1,1192 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! NVIDIA NVENC/NVDEC HW-accelerated AV1 encoder and decoder nodes. +//! +//! Uses the [`shiguredo_nvcodec`](https://crates.io/crates/shiguredo_nvcodec) +//! crate which provides Rust bindings for the NVIDIA Video Codec SDK. CUDA +//! driver API is loaded dynamically at runtime (`dlopen`) — no build-time +//! CUDA Toolkit dependency. +//! +//! This module provides: +//! - `NvAv1DecoderNode` — decodes AV1 packets to NV12 `VideoFrame`s via NVDEC +//! - `NvAv1EncoderNode` — encodes NV12 `VideoFrame`s to AV1 packets via NVENC +//! +//! Both nodes perform runtime capability detection: if no NVIDIA GPU with +//! AV1 support is found, node creation returns an error so the pipeline can +//! fall back to a CPU codec (rav1e/dav1d/SVT-AV1). +//! +//! # Feature gate +//! +//! Requires `nvcodec` feature. +//! +//! # GPU requirements +//! +//! - **AV1 decode**: NVIDIA RTX 30xx (Ampere) or newer. +//! - **AV1 encode**: NVIDIA RTX 40xx (Ada Lovelace) or newer. + +use async_trait::async_trait; +use bytes::Bytes; +use opentelemetry::global; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::sync::Arc; +use std::time::Instant; +use streamkit_core::stats::NodeStatsTracker; +use streamkit_core::types::{ + EncodedVideoFormat, Packet, PacketMetadata, PacketType, PixelFormat, RawVideoFormat, + VideoCodec, VideoFrame, VideoLayout, +}; +use streamkit_core::{ + config_helpers, get_codec_channel_capacity, packet_helpers, state_helpers, InputPin, + NodeContext, NodeRegistry, OutputPin, PinCardinality, PooledVideoData, ProcessorNode, + StreamKitError, VideoFramePool, +}; +use tokio::sync::mpsc; + +use super::encoder_trait::{self, EncodedPacket, EncoderNodeRunner, StandardVideoEncoder}; +use super::HwAccelMode; +use super::AV1_CONTENT_TYPE; + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +/// Configuration for the NVIDIA AV1 decoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct NvAv1DecoderConfig { + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, + /// CUDA device index (0-based). If `None`, use device 0. + pub cuda_device: Option, +} + +impl Default for NvAv1DecoderConfig { + fn default() -> Self { + Self { hw_accel: HwAccelMode::Auto, cuda_device: None } + } +} + +/// NVIDIA NVDEC AV1 decoder node. +/// +/// Accepts AV1 encoded `Binary` packets on its `"in"` pin and emits +/// decoded NV12 `VideoFrame`s on its `"out"` pin. +pub struct NvAv1DecoderNode { + config: NvAv1DecoderConfig, +} + +impl NvAv1DecoderNode { + /// Create a new decoder node with the given configuration. + /// + /// # Errors + /// + /// Returns an error if `hw_accel` is `ForceCpu` (this node only does HW). + pub fn new(config: NvAv1DecoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "NvAv1DecoderNode only supports hardware decoding; \ + use the CPU AV1 decoder (video::av1::decoder) for ForceCpu mode" + .to_string(), + )); + } + if config.cuda_device.is_some_and(|d| d > i32::MAX as u32) { + return Err(StreamKitError::Configuration(format!( + "cuda_device {} exceeds maximum CUDA device index ({})", + config.cuda_device.unwrap_or(0), + i32::MAX, + ))); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for NvAv1DecoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::Av1, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + })], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + async fn run(self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { + let node_name = context.output_sender.node_name().to_string(); + state_helpers::emit_initializing(&context.state_tx, &node_name); + + tracing::info!("NvAv1DecoderNode starting"); + let mut input_rx = context.take_input("in")?; + let video_pool = context.video_pool.clone(); + + let meter = global::meter("skit_nodes"); + let packets_processed_counter = + meter.u64_counter("nv_av1_decoder_packets_processed").build(); + let decode_duration_histogram = meter + .f64_histogram("nv_av1_decode_duration") + .with_boundaries(streamkit_core::metrics::HISTOGRAM_BOUNDARIES_CODEC_PACKET.to_vec()) + .build(); + + let (decode_tx, mut decode_rx) = + mpsc::channel::<(Bytes, Option)>(get_codec_channel_capacity()); + let (result_tx, mut result_rx) = + mpsc::channel::>(get_codec_channel_capacity()); + + let cuda_device = self.config.cuda_device.unwrap_or(0); + let decode_task = tokio::task::spawn_blocking(move || { + let nv_config = shiguredo_nvcodec::DecoderConfig { + #[allow(clippy::cast_possible_wrap)] + device_id: cuda_device as i32, + max_display_delay: 0, // low-latency + ..shiguredo_nvcodec::DecoderConfig::default() + }; + + let mut decoder = match shiguredo_nvcodec::Decoder::new_av1(nv_config) { + Ok(d) => d, + Err(err) => { + let _ = result_tx.blocking_send(Err(format!( + "NVDEC: failed to create AV1 decoder on GPU {cuda_device}: {err}" + ))); + return; + }, + }; + + tracing::info!("NVDEC AV1 decoder created on GPU {cuda_device}"); + + while let Some((data, metadata)) = decode_rx.blocking_recv() { + if result_tx.is_closed() { + return; + } + + if data.is_empty() { + continue; + } + + let decode_start_time = Instant::now(); + + if let Err(err) = decoder.decode(&data) { + tracing::warn!("NVDEC AV1 decode error: {err}"); + let _ = + result_tx.blocking_send(Err(format!("NVDEC: AV1 decode failed: {err}"))); + continue; + } + + // Drain all decoded frames produced by this input packet. + loop { + match decoder.next_frame() { + Ok(Some(nv_frame)) => { + match copy_nvdec_frame(&nv_frame, metadata.clone(), video_pool.as_ref()) + { + Ok(frame) => { + if result_tx.blocking_send(Ok(frame)).is_err() { + return; + } + }, + Err(err) => { + let _ = result_tx.blocking_send(Err(err)); + }, + } + }, + Ok(None) => break, + Err(err) => { + tracing::warn!("NVDEC next_frame error: {err}"); + let _ = result_tx + .blocking_send(Err(format!("NVDEC: next_frame failed: {err}"))); + break; + }, + } + } + + // Record decode duration once per input packet (after the + // entire decode + drain cycle), matching the AV1 CPU decoder + // pattern in av1.rs. + decode_duration_histogram.record(decode_start_time.elapsed().as_secs_f64(), &[]); + } + + // Flush remaining frames. + if result_tx.is_closed() { + return; + } + if let Err(err) = decoder.finish() { + tracing::warn!("NVDEC finish error: {err}"); + return; + } + loop { + match decoder.next_frame() { + Ok(Some(nv_frame)) => { + match copy_nvdec_frame(&nv_frame, None, video_pool.as_ref()) { + Ok(frame) => { + if result_tx.blocking_send(Ok(frame)).is_err() { + return; + } + }, + Err(err) => { + let _ = result_tx.blocking_send(Err(err)); + }, + } + }, + Ok(None) => break, + Err(err) => { + tracing::warn!("NVDEC flush next_frame error: {err}"); + break; + }, + } + } + }); + + state_helpers::emit_running(&context.state_tx, &node_name); + + let mut stats_tracker = NodeStatsTracker::new(node_name.clone(), context.stats_tx.clone()); + let batch_size = context.batch_size; + + let decode_tx_clone = decode_tx.clone(); + let mut input_task = tokio::spawn(async move { + loop { + let Some(first_packet) = input_rx.recv().await else { + break; + }; + + let packet_batch = + packet_helpers::batch_packets_greedy(first_packet, &mut input_rx, batch_size); + + for packet in packet_batch { + if let Packet::Binary { data, metadata, .. } = packet { + if decode_tx_clone.send((data, metadata)).await.is_err() { + tracing::error!( + "NvAv1DecoderNode decode task has shut down unexpectedly" + ); + return; + } + } + } + } + tracing::info!("NvAv1DecoderNode input stream closed"); + }); + + crate::codec_utils::codec_forward_loop( + &mut context, + &mut result_rx, + &mut input_task, + decode_task, + decode_tx, + &packets_processed_counter, + &mut stats_tracker, + Packet::Video, + "NvAv1DecoderNode", + ) + .await; + + state_helpers::emit_stopped(&context.state_tx, &node_name, "input_closed"); + tracing::info!("NvAv1DecoderNode finished"); + Ok(()) + } +} + +/// Copy a decoded NV12 frame from `shiguredo_nvcodec` into a `VideoFrame`. +/// +/// The `DecodedFrame` already provides NV12 data (separate Y and interleaved +/// UV planes), so we copy them into a contiguous buffer with the canonical +/// packed NV12 layout. +fn copy_nvdec_frame( + decoded: &shiguredo_nvcodec::DecodedFrame, + metadata: Option, + video_pool: Option<&Arc>, +) -> Result { + #[allow(clippy::cast_possible_truncation)] + let width = decoded.width() as u32; + #[allow(clippy::cast_possible_truncation)] + let height = decoded.height() as u32; + + if width == 0 || height == 0 { + return Err("NVDEC produced empty frame".to_string()); + } + + let nv12_layout = VideoLayout::packed(width, height, PixelFormat::Nv12); + let mut data = video_pool.map_or_else( + || PooledVideoData::from_vec(vec![0u8; nv12_layout.total_bytes()]), + |pool| pool.get(nv12_layout.total_bytes()), + ); + let data_slice = data.as_mut_slice(); + + let nv12_planes = nv12_layout.planes(); + let y_plane = nv12_planes[0]; + let uv_plane = nv12_planes[1]; + + // Copy Y plane. + let y_src = decoded.y_plane(); + let y_src_stride = decoded.y_stride(); + let width_usize = width as usize; + let height_usize = height as usize; + + for row in 0..height_usize { + let src_start = row * y_src_stride; + let src_end = src_start + width_usize; + if src_end > y_src.len() { + return Err(format!("NVDEC Y plane too small: need {src_end}, have {}", y_src.len())); + } + let dst_start = y_plane.offset + row * y_plane.stride; + let dst_end = dst_start + width_usize; + if dst_end > data_slice.len() { + return Err("NVDEC Y plane copy overflow".to_string()); + } + data_slice[dst_start..dst_end].copy_from_slice(&y_src[src_start..src_end]); + } + + // Copy UV plane (already interleaved NV12 format from NVDEC). + let uv_src = decoded.uv_plane(); + let uv_src_stride = decoded.uv_stride(); + let chroma_h = uv_plane.height as usize; + let uv_row_bytes = uv_plane.width as usize; // NV12: ceil(width/2) interleaved UV pairs + + for row in 0..chroma_h { + let src_start = row * uv_src_stride; + let src_end = src_start + uv_row_bytes; + if src_end > uv_src.len() { + return Err(format!("NVDEC UV plane too small: need {src_end}, have {}", uv_src.len())); + } + let dst_start = uv_plane.offset + row * uv_plane.stride; + let dst_end = dst_start + uv_row_bytes; + if dst_end > data_slice.len() { + return Err("NVDEC UV plane copy overflow".to_string()); + } + data_slice[dst_start..dst_end].copy_from_slice(&uv_src[src_start..src_end]); + } + + VideoFrame::from_pooled(width, height, PixelFormat::Nv12, data, metadata) + .map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// Encoder +// --------------------------------------------------------------------------- + +/// Configuration for the NVIDIA AV1 encoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct NvAv1EncoderConfig { + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, + /// CUDA device index (0-based). If `None`, use device 0. + pub cuda_device: Option, + /// Target bitrate in bits per second. + pub bitrate: u32, + /// Target framerate in frames per second. + pub framerate: u32, + /// Keyframe interval (GOP length). `None` uses the NVENC default + /// (infinite GOP). + pub keyframe_interval: Option, +} + +impl Default for NvAv1EncoderConfig { + fn default() -> Self { + Self { + hw_accel: HwAccelMode::Auto, + cuda_device: None, + bitrate: 2_000_000, + framerate: 30, + keyframe_interval: None, + } + } +} + +/// NVIDIA NVENC AV1 encoder node. +/// +/// Accepts NV12/I420 `VideoFrame`s on its `"in"` pin and emits AV1 +/// encoded `Binary` packets on its `"out"` pin. +pub struct NvAv1EncoderNode { + config: NvAv1EncoderConfig, +} + +impl NvAv1EncoderNode { + /// Create a new encoder node with the given configuration. + /// + /// # Errors + /// + /// Returns an error if `hw_accel` is `ForceCpu` (this node only does HW). + pub fn new(config: NvAv1EncoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "NvAv1EncoderNode only supports hardware encoding; \ + use the CPU AV1 encoder (video::av1::encoder) for ForceCpu mode" + .to_string(), + )); + } + if config.cuda_device.is_some_and(|d| d > i32::MAX as u32) { + return Err(StreamKitError::Configuration(format!( + "cuda_device {} exceeds maximum CUDA device index ({})", + config.cuda_device.unwrap_or(0), + i32::MAX, + ))); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for NvAv1EncoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![ + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::I420, + }), + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + ], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::Av1, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + fn content_type(&self) -> Option { + Some(AV1_CONTENT_TYPE.to_string()) + } + + async fn run(self: Box, context: NodeContext) -> Result<(), StreamKitError> { + encoder_trait::run_encoder(*self, context).await + } +} + +impl EncoderNodeRunner for NvAv1EncoderNode { + const CONTENT_TYPE: &'static str = AV1_CONTENT_TYPE; + const NODE_LABEL: &'static str = "NvAv1EncoderNode"; + const PACKETS_COUNTER_NAME: &'static str = "nv_av1_encoder_packets_processed"; + const DURATION_HISTOGRAM_NAME: &'static str = "nv_av1_encode_duration"; + + fn spawn_codec_task( + self, + encode_rx: mpsc::Receiver<(VideoFrame, Option)>, + result_tx: mpsc::Sender>, + duration_histogram: opentelemetry::metrics::Histogram, + ) -> tokio::task::JoinHandle<()> { + encoder_trait::spawn_standard_encode_task::( + self.config, + encode_rx, + result_tx, + duration_histogram, + ) + } +} + +// --------------------------------------------------------------------------- +// Internal NVENC wrapper implementing StandardVideoEncoder +// --------------------------------------------------------------------------- + +struct NvAv1Encoder { + encoder: shiguredo_nvcodec::Encoder, + next_pts: i64, +} + +impl StandardVideoEncoder for NvAv1Encoder { + type Config = NvAv1EncoderConfig; + const CODEC_NAME: &'static str = "NV-AV1"; + + fn new_encoder(width: u32, height: u32, config: &Self::Config) -> Result + where + Self: Sized, + { + let cuda_device = config.cuda_device.unwrap_or(0); + + let nv_config = shiguredo_nvcodec::EncoderConfig { + width, + height, + fps_numerator: config.framerate, + fps_denominator: 1, + target_bitrate: Some(config.bitrate), + preset: shiguredo_nvcodec::Preset::P1, // fastest for real-time + tuning_info: shiguredo_nvcodec::TuningInfo::LOW_LATENCY, + rate_control_mode: shiguredo_nvcodec::RateControlMode::Cbr, + gop_length: config.keyframe_interval, + idr_period: config.keyframe_interval, + frame_interval_p: 1, // no B-frames for low latency + profile: None, + #[allow(clippy::cast_possible_wrap)] + device_id: cuda_device as i32, + max_encode_width: None, + max_encode_height: None, + }; + + let encoder = shiguredo_nvcodec::Encoder::new_av1(nv_config).map_err(|err| { + format!("NVENC: failed to create AV1 encoder on GPU {cuda_device}: {err}") + })?; + + tracing::info!( + width, + height, + bitrate = config.bitrate, + framerate = config.framerate, + gpu = cuda_device, + "NVENC AV1 encoder created" + ); + + Ok(Self { encoder, next_pts: 0 }) + } + + fn encode( + &mut self, + frame: &VideoFrame, + metadata: Option, + ) -> Result, String> { + let nv12_data = match frame.pixel_format { + PixelFormat::Nv12 => Cow::Borrowed(frame.data.as_slice()), + PixelFormat::I420 => Cow::Owned(super::i420_frame_to_nv12_buffer(frame)), + other => { + return Err(format!("NV-AV1 encoder expects NV12 or I420 input, got {other:?}")); + }, + }; + + self.encoder + .encode(&nv12_data) + .map_err(|err| format!("NVENC: AV1 encode failed: {err}"))?; + + Ok(self.drain_packets(metadata)) + } + + fn flush_encoder(&mut self) -> Result, String> { + self.encoder.finish().map_err(|err| format!("NVENC: AV1 finish failed: {err}"))?; + + Ok(self.drain_packets(None)) + } + + fn flush_on_dimension_change() -> bool { + true + } +} + +impl NvAv1Encoder { + /// Drain all available encoded frames from NVENC. + fn drain_packets(&mut self, metadata: Option) -> Vec { + let mut packets = Vec::new(); + let mut remaining_metadata = metadata; + + loop { + let Some(encoded) = self.encoder.next_frame() else { + break; + }; + + let is_keyframe = matches!( + encoded.picture_type(), + shiguredo_nvcodec::PictureType::I | shiguredo_nvcodec::PictureType::Idr + ); + let data = Bytes::from(encoded.into_data()); + + let pts = self.next_pts; + self.next_pts += 1; + + let meta = remaining_metadata.take(); + let output_metadata = merge_keyframe_metadata(meta, is_keyframe, pts); + + packets.push(EncodedPacket { data, metadata: Some(output_metadata) }); + } + + packets + } +} + +// I420→NV12 conversion is now in super::i420_frame_to_nv12_buffer(). + +#[allow(clippy::missing_const_for_fn)] // map_or with closures is not yet stable in const fn +fn merge_keyframe_metadata( + metadata: Option, + keyframe: bool, + pts: i64, +) -> PacketMetadata { + metadata.map_or( + PacketMetadata { + #[allow(clippy::cast_sign_loss)] + timestamp_us: if pts >= 0 { Some(pts as u64) } else { None }, + duration_us: None, + sequence: None, + keyframe: Some(keyframe), + }, + |meta| PacketMetadata { + timestamp_us: meta.timestamp_us, + duration_us: meta.duration_us, + sequence: meta.sequence, + keyframe: Some(keyframe), + }, + ) +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +use schemars::schema_for; +use streamkit_core::registry::StaticPins; + +#[allow(clippy::expect_used, clippy::missing_panics_doc)] +pub fn register_nv_av1_nodes(registry: &mut NodeRegistry) { + // Runtime capability check: verify that CUDA libraries are loadable. + // If not, log a warning but still register the nodes — they will fail + // at runtime with a clear error when the pipeline starts. + if !shiguredo_nvcodec::is_cuda_library_available() { + tracing::warn!( + "CUDA libraries not available — NV AV1 encoder/decoder nodes \ + will fail at runtime if used" + ); + } + + let default_decoder = NvAv1DecoderNode::new(NvAv1DecoderConfig::default()) + .expect("default NV AV1 decoder config should be valid"); + registry.register_static_with_description( + "video::nv::av1_decoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(NvAv1DecoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(NvAv1DecoderConfig)) + .expect("NvAv1DecoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_decoder.input_pins(), outputs: default_decoder.output_pins() }, + vec![ + "video".to_string(), + "codecs".to_string(), + "av1".to_string(), + "hw".to_string(), + "nvidia".to_string(), + ], + false, + "Decodes AV1-compressed packets into raw NV12 video frames using \ + NVIDIA NVDEC hardware acceleration. Requires an NVIDIA RTX 30xx \ + (Ampere) or newer GPU.", + ); + + let default_encoder = NvAv1EncoderNode::new(NvAv1EncoderConfig::default()) + .expect("default NV AV1 encoder config should be valid"); + registry.register_static_with_description( + "video::nv::av1_encoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(NvAv1EncoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(NvAv1EncoderConfig)) + .expect("NvAv1EncoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_encoder.input_pins(), outputs: default_encoder.output_pins() }, + vec![ + "video".to_string(), + "codecs".to_string(), + "av1".to_string(), + "hw".to_string(), + "nvidia".to_string(), + ], + false, + "Encodes raw video frames (NV12 or I420) into AV1 packets using \ + NVIDIA NVENC hardware acceleration. Requires an NVIDIA RTX 40xx \ + (Ada Lovelace) or newer GPU. Insert a video::pixel_convert node \ + upstream if the source outputs RGBA8.", + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_macros)] +mod tests { + use super::*; + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, create_test_context, + create_test_video_frame, + }; + use std::borrow::Cow; + use std::collections::HashMap; + use tokio::sync::mpsc; + + // ── Helpers ───────────────────────────────────────────────────────────── + + /// Returns `true` if CUDA libraries can be loaded AND a decoder can + /// actually be created on device 0. This catches machines that have + /// `libcuda.so` but no physical GPU (or no AV1-capable GPU). + fn nvdec_av1_available() -> bool { + if !shiguredo_nvcodec::is_cuda_library_available() { + return false; + } + let config = shiguredo_nvcodec::DecoderConfig { + device_id: 0, + max_display_delay: 0, + ..shiguredo_nvcodec::DecoderConfig::default() + }; + shiguredo_nvcodec::Decoder::new_av1(config).is_ok() + } + + /// Returns `true` if NVENC AV1 encoding is available on device 0. + /// AV1 encode requires RTX 40xx (Ada Lovelace) or newer. + fn nvenc_av1_available() -> bool { + if !shiguredo_nvcodec::is_cuda_library_available() { + return false; + } + let config = shiguredo_nvcodec::EncoderConfig { + width: 64, + height: 64, + fps_numerator: 30, + fps_denominator: 1, + target_bitrate: Some(2_000_000), + preset: shiguredo_nvcodec::Preset::P1, + tuning_info: shiguredo_nvcodec::TuningInfo::LOW_LATENCY, + rate_control_mode: shiguredo_nvcodec::RateControlMode::Cbr, + gop_length: Some(1), + idr_period: Some(1), + frame_interval_p: 1, + profile: None, + device_id: 0, + max_encode_width: None, + max_encode_height: None, + }; + shiguredo_nvcodec::Encoder::new_av1(config).is_ok() + } + + // ── Unit tests (no GPU required) ──────────────────────────────────────── + + #[test] + fn force_cpu_decoder_rejected() { + let result = NvAv1DecoderNode::new(NvAv1DecoderConfig { + hw_accel: HwAccelMode::ForceCpu, + cuda_device: None, + }); + assert!(result.is_err(), "ForceCpu should be rejected by NV decoder"); + } + + #[test] + fn force_cpu_encoder_rejected() { + let result = NvAv1EncoderNode::new(NvAv1EncoderConfig { + hw_accel: HwAccelMode::ForceCpu, + cuda_device: None, + bitrate: 2_000_000, + framerate: 30, + keyframe_interval: None, + }); + assert!(result.is_err(), "ForceCpu should be rejected by NV encoder"); + } + + #[test] + fn default_configs_accepted() { + assert!(NvAv1DecoderNode::new(NvAv1DecoderConfig::default()).is_ok()); + assert!(NvAv1EncoderNode::new(NvAv1EncoderConfig::default()).is_ok()); + } + + #[test] + fn rejects_cuda_device_exceeding_i32_max() { + let bad_device = i32::MAX as u32 + 1; + let dec_result = NvAv1DecoderNode::new(NvAv1DecoderConfig { + cuda_device: Some(bad_device), + ..NvAv1DecoderConfig::default() + }); + assert!(dec_result.is_err(), "cuda_device > i32::MAX should be rejected by decoder"); + + let enc_result = NvAv1EncoderNode::new(NvAv1EncoderConfig { + cuda_device: Some(bad_device), + ..NvAv1EncoderConfig::default() + }); + assert!(enc_result.is_err(), "cuda_device > i32::MAX should be rejected by encoder"); + } + + #[test] + fn decoder_pins_correct() { + let node = NvAv1DecoderNode::new(NvAv1DecoderConfig::default()).unwrap(); + let inputs = node.input_pins(); + let outputs = node.output_pins(); + assert_eq!(inputs.len(), 1); + assert_eq!(outputs.len(), 1); + assert_eq!(inputs[0].name, "in"); + assert_eq!(outputs[0].name, "out"); + assert!( + matches!(&inputs[0].accepts_types[0], PacketType::EncodedVideo(fmt) if fmt.codec == VideoCodec::Av1), + "Decoder input should accept AV1" + ); + assert!( + matches!(&outputs[0].produces_type, PacketType::RawVideo(fmt) if fmt.pixel_format == PixelFormat::Nv12), + "Decoder output should produce NV12" + ); + } + + #[test] + fn encoder_pins_correct() { + let node = NvAv1EncoderNode::new(NvAv1EncoderConfig::default()).unwrap(); + let inputs = node.input_pins(); + let outputs = node.output_pins(); + assert_eq!(inputs.len(), 1); + assert_eq!(outputs.len(), 1); + assert_eq!(inputs[0].name, "in"); + assert_eq!(outputs[0].name, "out"); + // Encoder should accept both I420 and NV12. + assert_eq!(inputs[0].accepts_types.len(), 2); + assert!( + matches!(&outputs[0].produces_type, PacketType::EncodedVideo(fmt) if fmt.codec == VideoCodec::Av1), + "Encoder output should produce AV1" + ); + } + + #[test] + fn deny_unknown_fields_decoder() { + let json = r#"{"hw_accel":"Auto","cuda_device":null,"bogus_field":42}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "Unknown fields should be rejected"); + } + + #[test] + fn deny_unknown_fields_encoder() { + let json = r#"{"bitrate":1000000,"unknown_key":"oops"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "Unknown fields should be rejected"); + } + + #[test] + fn i420_to_nv12_basic() { + // Build a minimal 4×4 I420 frame and convert. + let frame = create_test_video_frame(4, 4, PixelFormat::I420, 1); + let nv12 = crate::video::i420_frame_to_nv12_buffer(&frame); + + // NV12 size: Y (4*4) + UV (ceil(4/2)*2 * ceil(4/2)) = 16 + 4*2 = 24 + let expected_size = 4 * 4 + 4 * 2; + assert_eq!(nv12.len(), expected_size, "NV12 buffer size mismatch"); + } + + // ── GPU integration tests ─────────────────────────────────────────────── + + /// Encode several NV12 frames via NVENC, then decode them via NVDEC. + /// This is the full HW roundtrip test. + #[tokio::test] + async fn gpu_tests_nv_av1_encode_decode_roundtrip() { + if !nvenc_av1_available() { + eprintln!("Skipping NV AV1 encode/decode roundtrip: NVENC AV1 not available"); + return; + } + if !nvdec_av1_available() { + eprintln!("Skipping NV AV1 encode/decode roundtrip: NVDEC AV1 not available"); + return; + } + + // --- Encode --- + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder_config = NvAv1EncoderConfig { + bitrate: 2_000_000, + keyframe_interval: Some(1), + ..Default::default() + }; + let encoder = NvAv1EncoderNode::new(encoder_config).unwrap(); + + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + for index in 0_u64..5 { + let timestamp = 1_000 + 33_333_u64 * index; + let duration: u64 = 33_333; + + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(timestamp), + duration_us: Some(duration), + sequence: Some(index), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "NVENC AV1 encoder produced no packets"); + + // --- Decode --- + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = NvAv1DecoderNode::new(NvAv1DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + if let Packet::Binary { data, metadata, .. } = packet { + dec_input_tx + .send(Packet::Binary { + data, + content_type: Some(Cow::Borrowed(AV1_CONTENT_TYPE)), + metadata, + }) + .await + .unwrap(); + } + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "NVDEC AV1 decoder produced no frames"); + + for packet in decoded_packets { + match packet { + Packet::Video(frame) => { + assert_eq!(frame.width, 64); + assert_eq!(frame.height, 64); + assert_eq!(frame.pixel_format, PixelFormat::Nv12); + assert!(!frame.data().is_empty(), "Decoded frame should have data"); + }, + _ => panic!("Expected Video packet from NV AV1 decoder"), + } + } + } + + /// Encode-only test: verify that the encoder produces output packets + /// and that the first packet is marked as a keyframe. + #[tokio::test] + async fn gpu_tests_nv_av1_encoder_produces_keyframes() { + if !nvenc_av1_available() { + eprintln!("Skipping NV AV1 encoder keyframe test: NVENC AV1 not available"); + return; + } + + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder_config = NvAv1EncoderConfig { + bitrate: 2_000_000, + keyframe_interval: Some(1), + ..Default::default() + }; + let encoder = NvAv1EncoderNode::new(encoder_config).unwrap(); + + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + for index in 0_u64..3 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * index), + duration_us: Some(33_333), + sequence: Some(index), + keyframe: None, + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "NVENC AV1 encoder produced no packets"); + + // With keyframe_interval=1, every packet should be a keyframe. + for (i, packet) in encoded_packets.iter().enumerate() { + if let Packet::Binary { metadata, .. } = packet { + let meta = metadata.as_ref().expect("Encoded packet should have metadata"); + assert_eq!( + meta.keyframe, + Some(true), + "Packet {i} should be a keyframe with keyframe_interval=1" + ); + } + } + } + + /// Encode from I420 input — verifies the I420→NV12 conversion path. + #[tokio::test] + async fn gpu_tests_nv_av1_encoder_i420_input() { + if !nvenc_av1_available() { + eprintln!("Skipping NV AV1 I420 input test: NVENC AV1 not available"); + return; + } + + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder_config = NvAv1EncoderConfig { + bitrate: 2_000_000, + keyframe_interval: Some(1), + ..Default::default() + }; + let encoder = NvAv1EncoderNode::new(encoder_config).unwrap(); + + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + // Send I420 frames instead of NV12. + for index in 0_u64..3 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::I420, 1); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * index), + duration_us: Some(33_333), + sequence: Some(index), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!( + !encoded_packets.is_empty(), + "NVENC AV1 encoder produced no packets from I420 input" + ); + } + + /// Metadata propagation: timestamps from input frames should be + /// preserved through the encode→decode roundtrip. + #[tokio::test] + async fn gpu_tests_nv_av1_metadata_propagation() { + if !nvenc_av1_available() || !nvdec_av1_available() { + eprintln!("Skipping NV AV1 metadata test: NVENC/NVDEC AV1 not available"); + return; + } + + // --- Encode --- + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder_config = NvAv1EncoderConfig { + bitrate: 2_000_000, + keyframe_interval: Some(1), + ..Default::default() + }; + let encoder = NvAv1EncoderNode::new(encoder_config).unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + let timestamps: Vec = vec![1_000, 34_333, 67_666]; + for (i, &ts) in timestamps.iter().enumerate() { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(ts), + duration_us: Some(33_333), + sequence: Some(i as u64), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty()); + + // --- Decode and verify metadata --- + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = NvAv1DecoderNode::new(NvAv1DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + if let Packet::Binary { data, metadata, .. } = packet { + dec_input_tx + .send(Packet::Binary { + data, + content_type: Some(Cow::Borrowed(AV1_CONTENT_TYPE)), + metadata, + }) + .await + .unwrap(); + } + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "Decoder should produce at least one frame"); + + // Every decoded frame should have metadata preserved. + for (i, packet) in decoded_packets.iter().enumerate() { + match packet { + Packet::Video(frame) => { + assert!(frame.metadata.is_some(), "Decoded frame {i} should have metadata"); + }, + _ => panic!("Expected Video packet from NV AV1 decoder"), + } + } + } + + // ── Registration test ──────────────────────────────────────────────── + + #[test] + fn test_node_registration() { + let mut registry = NodeRegistry::new(); + register_nv_av1_nodes(&mut registry); + + assert!( + registry.create_node("video::nv::av1_decoder", None).is_ok(), + "NV AV1 decoder should be registered" + ); + assert!( + registry.create_node("video::nv::av1_encoder", None).is_ok(), + "NV AV1 encoder should be registered" + ); + } +} diff --git a/crates/nodes/src/video/vaapi_av1.rs b/crates/nodes/src/video/vaapi_av1.rs new file mode 100644 index 00000000..d422ad7d --- /dev/null +++ b/crates/nodes/src/video/vaapi_av1.rs @@ -0,0 +1,2339 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! VA-API HW-accelerated AV1 encoder and decoder nodes. +//! +//! Uses the [`cros-codecs`](https://crates.io/crates/cros-codecs) crate which +//! provides high-level VA-API AV1 codec abstractions on Linux. The cros-codecs +//! `StatelessDecoder` and `StatelessEncoder` handle all AV1 bitstream parsing +//! and VA-API parameter buffer construction internally — this module manages +//! frame I/O and integrates with StreamKit's pipeline architecture. +//! +//! # Nodes +//! +//! - [`VaapiAv1DecoderNode`] — decodes AV1 OBU packets to NV12 [`VideoFrame`]s +//! - [`VaapiAv1EncoderNode`] — encodes NV12/I420 [`VideoFrame`]s to AV1 packets +//! +//! Both perform runtime capability detection: if no VA-API device is found (or +//! AV1 is not supported), node creation returns an error so the pipeline can +//! fall back to a CPU codec (rav1e/dav1d/SVT-AV1). +//! +//! # Feature gate +//! +//! Requires `vaapi` Cargo feature and `libva-dev` + `libgbm-dev` system packages. +//! +//! # Platform support +//! +//! - **Intel**: Full AV1 encode (Arc+) and decode via `intel-media-driver`. +//! - **AMD**: AV1 encode + decode via Mesa RadeonSI VA-API. +//! - **NVIDIA**: Decode only via community `nvidia-vaapi-driver` (no VA-API encoding). + +use std::rc::Rc; +use std::sync::Arc; +use std::time::Instant; + +use async_trait::async_trait; +use bytes::Bytes; +use opentelemetry::global; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use streamkit_core::stats::NodeStatsTracker; +use streamkit_core::types::{ + EncodedVideoFormat, Packet, PacketMetadata, PacketType, PixelFormat, RawVideoFormat, + VideoCodec, VideoFrame, +}; +use streamkit_core::{ + config_helpers, get_codec_channel_capacity, packet_helpers, state_helpers, InputPin, + NodeContext, NodeRegistry, OutputPin, PinCardinality, ProcessorNode, StreamKitError, +}; +use tokio::sync::mpsc; + +// cros-codecs high-level APIs. +use cros_codecs::backend::vaapi::decoder::VaapiBackend as VaapiDecBackend; +use cros_codecs::codec::av1::parser::Profile as Av1Profile; +use cros_codecs::decoder::stateless::av1::Av1; +use cros_codecs::decoder::stateless::{DecodeError, StatelessDecoder, StatelessVideoDecoder}; +use cros_codecs::decoder::{BlockingMode, DecodedHandle, DecoderEvent}; +use cros_codecs::encoder::av1::EncoderConfig as CrosEncoderConfig; +use cros_codecs::encoder::stateless::StatelessEncoder; +use cros_codecs::encoder::{ + FrameMetadata as CrosFrameMetadata, PredictionStructure, RateControl, Tunings, VideoEncoder, +}; +use cros_codecs::libva; +use cros_codecs::video_frame::gbm_video_frame::{ + GbmDevice, GbmExternalBufferDescriptor, GbmUsage, GbmVideoFrame, +}; +use cros_codecs::video_frame::{ReadMapping, VideoFrame as CrosVideoFrame, WriteMapping}; +use cros_codecs::{Fourcc as CrosFourcc, FrameLayout, PlaneLayout, Resolution as CrosResolution}; + +use super::encoder_trait::{self, EncodedPacket, EncoderNodeRunner, StandardVideoEncoder}; +use super::HwAccelMode; +use super::AV1_CONTENT_TYPE; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Default VA-API render device path. +const DEFAULT_RENDER_DEVICE: &str = "/dev/dri/renderD128"; + +/// AV1 superblock size — coded resolution must be aligned to this. +const AV1_SB_SIZE: u32 = 64; + +/// Maximum number of consecutive retries when the decoder returns +/// `CheckEvents` or `NotEnoughOutputBuffers` without making progress. +/// Matches the established pattern in `av1.rs` and `dav1d.rs`. +const MAX_EAGAIN_EMPTY_RETRIES: u32 = 1000; + +/// After this many retries, switch from `thread::yield_now()` to +/// `thread::sleep(1ms)` to avoid a tight spin-loop. +const EAGAIN_YIELD_THRESHOLD: u32 = 10; + +/// Default constant-quality parameter (0–255, lower = better quality). +const DEFAULT_QUALITY: u32 = 128; + +/// Default framerate for rate-control hints. +const DEFAULT_FRAMERATE: u32 = 30; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// NV12 fourcc code for GBM/VA-API surfaces. +pub(super) fn nv12_fourcc() -> CrosFourcc { + CrosFourcc::from(b"NV12") +} + +/// Align `value` up to the next multiple of `alignment`. +pub(super) fn align_up_u32(value: u32, alignment: u32) -> u32 { + debug_assert!(alignment > 0); + value.div_ceil(alignment) * alignment +} + +/// Auto-detect a VA-API render device by scanning `/dev/dri/renderD*`. +/// +/// Returns the first device path that can be opened as a VA display, or `None` +/// if no VA-API capable device is found. +fn detect_render_device() -> Option { + let mut entries: Vec<_> = std::fs::read_dir("/dev/dri") + .ok()? + .filter_map(std::result::Result::ok) + .filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("renderD"))) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + + for entry in entries { + let path = entry.path(); + if libva::Display::open_drm_display(&path).is_ok() { + return path.to_str().map(String::from); + } + } + + None +} + +/// Resolve the render device path from config, auto-detection, or default. +pub(super) fn resolve_render_device(configured: Option<&String>) -> String { + if let Some(path) = configured { + return path.clone(); + } + + if let Some(path) = detect_render_device() { + tracing::info!(device = %path, "auto-detected VA-API render device"); + return path; + } + + tracing::info!( + device = DEFAULT_RENDER_DEVICE, + "no VA-API device detected, falling back to default" + ); + DEFAULT_RENDER_DEVICE.to_string() +} + +/// Open a VA display and a GBM device on the same render node. +pub(super) fn open_va_and_gbm( + render_device: Option<&String>, +) -> Result<(Rc, Arc, String), String> { + let path = resolve_render_device(render_device); + let display = libva::Display::open_drm_display(&path) + .map_err(|e| format!("failed to open VA display on {path}: {e}"))?; + let gbm = + GbmDevice::open(&path).map_err(|e| format!("failed to open GBM device on {path}: {e}"))?; + Ok((display, gbm, path)) +} + +/// Open a VA display without a GBM device. +/// +/// Used by encoder paths that pass VA surfaces directly to the encoder, +/// bypassing GBM buffer allocation entirely. This avoids the +/// `GBM_BO_USE_HW_VIDEO_ENCODER` flag that Mesa's iris driver does not +/// support for NV12 on some hardware (e.g. Intel Tiger Lake). +pub(super) fn open_va_display( + render_device: Option<&String>, +) -> Result<(Rc, String), String> { + let path = resolve_render_device(render_device); + let display = libva::Display::open_drm_display(&path) + .map_err(|e| format!("failed to open VA display on {path}: {e}"))?; + Ok((display, path)) +} + +/// Write NV12 (or I420→NV12) data from a StreamKit [`VideoFrame`] into a VA +/// surface using the VA-API Image API. +/// +/// Uses `vaCreateImage` + `vaMapBuffer` to obtain a writable mapping, writes +/// NV12 data respecting the driver's internal pitches/offsets, then drops the +/// [`Image`] which flushes the data back via `vaPutImage`. +/// +/// Returns `(pitches, offsets)` — the per-plane stride and byte-offset arrays +/// from the `VAImage`, needed to build the [`FrameLayout`] for the encoder. +pub(super) fn write_nv12_to_va_surface( + display: &Rc, + surface: &libva::Surface<()>, + frame: &VideoFrame, +) -> Result<([usize; 2], [usize; 2]), String> { + let nv12_fourcc_val: u32 = nv12_fourcc().into(); + let image_fmts = display + .query_image_formats() + .map_err(|e| format!("failed to query VA image formats: {e}"))?; + let image_fmt = image_fmts + .into_iter() + .find(|f| f.fourcc == nv12_fourcc_val) + .ok_or("VA driver does not support NV12 image format")?; + + let mut image = libva::Image::create_from(surface, image_fmt, surface.size(), surface.size()) + .map_err(|e| format!("failed to create VA image for NV12 upload: {e}"))?; + + let va_image = *image.image(); + let y_pitch = va_image.pitches[0] as usize; + let uv_pitch = va_image.pitches[1] as usize; + let y_offset = va_image.offsets[0] as usize; + let uv_offset = va_image.offsets[1] as usize; + + let dest = image.as_mut(); + let src = frame.data.as_ref().as_ref(); + let w = frame.width as usize; + let h = frame.height as usize; + + // The NV12 and I420 branches below assume a packed layout (stride == width). + // StreamKit's VideoLayout::packed() guarantees this, but assert it so + // future layout changes are caught early. + debug_assert!( + frame.layout().planes()[0].stride == w, + "write_nv12_to_va_surface expects packed layout (Y stride {} != width {w})", + frame.layout().planes()[0].stride, + ); + + match frame.pixel_format { + PixelFormat::Nv12 => { + // Y plane. + for row in 0..h { + let s = row * w; + let d = y_offset + row * y_pitch; + if s + w > src.len() || d + w > dest.len() { + return Err(format!( + "NV12 Y row copy out of bounds: src[{}..{}] (len {}), dest[{}..{}] (len {})", + s, s + w, src.len(), d, d + w, dest.len() + )); + } + dest[d..d + w].copy_from_slice(&src[s..s + w]); + } + // UV plane (already interleaved in NV12). + let uv_h = h.div_ceil(2); + let chroma_w = w.div_ceil(2) * 2; + let src_uv = &src[w * h..]; + for row in 0..uv_h { + let s = row * chroma_w; + let d = uv_offset + row * uv_pitch; + if s + chroma_w > src_uv.len() || d + chroma_w > dest.len() { + return Err(format!( + "NV12 UV row copy out of bounds: src[{}..{}] (len {}), dest[{}..{}] (len {})", + s, s + chroma_w, src_uv.len(), d, d + chroma_w, dest.len() + )); + } + dest[d..d + chroma_w].copy_from_slice(&src_uv[s..s + chroma_w]); + } + }, + PixelFormat::I420 => { + // Y plane — same as NV12. + for row in 0..h { + let s = row * w; + let d = y_offset + row * y_pitch; + if s + w > src.len() || d + w > dest.len() { + return Err(format!( + "I420 Y row copy out of bounds: src[{}..{}] (len {}), dest[{}..{}] (len {})", + s, s + w, src.len(), d, d + w, dest.len() + )); + } + dest[d..d + w].copy_from_slice(&src[s..s + w]); + } + // I420 → NV12: interleave U and V into a single UV plane. + let uv_w = w.div_ceil(2); + let uv_h = h.div_ceil(2); + let u_start = w * h; + let v_start = u_start + uv_w * uv_h; + for row in 0..uv_h { + for col in 0..uv_w { + let u_idx = u_start + row * uv_w + col; + let v_idx = v_start + row * uv_w + col; + let d = uv_offset + row * uv_pitch + col * 2; + if u_idx >= src.len() || v_idx >= src.len() || d + 1 >= dest.len() { + return Err(format!( + "I420 UV interleave out of bounds: u_idx={u_idx}, v_idx={v_idx} \ + (src len {}), dst_idx={d} (dest len {})", + src.len(), + dest.len() + )); + } + dest[d] = src[u_idx]; + dest[d + 1] = src[v_idx]; + } + } + }, + other => { + drop(image); + return Err(format!("write_nv12_to_va_surface: unsupported pixel format {other:?}")); + }, + } + + // Drop `image` to flush data back to the surface via vaPutImage. + drop(image); + + Ok(([y_pitch, uv_pitch], [y_offset, uv_offset])) +} + +/// Copy NV12 plane data from a GBM read-mapping into a flat `Vec` suitable +/// for a packed StreamKit [`VideoFrame`]. +/// +/// Handles stride != width by copying row-by-row. +pub(super) fn read_nv12_from_mapping( + mapping: &dyn ReadMapping<'_>, + width: u32, + height: u32, + plane_pitches: &[usize], +) -> Vec { + let planes = mapping.get(); + let w = width as usize; + let h = height as usize; + let y_size = w * h; + let uv_h = h.div_ceil(2); + // NV12 UV row width: interleaved U/V pairs, matching VideoLayout::packed. + let chroma_w = w.div_ceil(2) * 2; + let uv_size = chroma_w * uv_h; + let mut data = vec![0u8; y_size + uv_size]; + + // Y plane. + if !planes.is_empty() { + let y_stride = plane_pitches.first().copied().unwrap_or(w); + if y_stride == w { + let copy_len = y_size.min(planes[0].len()); + data[..copy_len].copy_from_slice(&planes[0][..copy_len]); + } else { + for row in 0..h { + let dst_off = row * w; + let src_off = row * y_stride; + if src_off + w <= planes[0].len() && dst_off + w <= y_size { + data[dst_off..dst_off + w].copy_from_slice(&planes[0][src_off..src_off + w]); + } + } + } + } + + // UV plane (interleaved). + if planes.len() > 1 { + let uv_stride = plane_pitches.get(1).copied().unwrap_or(chroma_w); + if uv_stride == chroma_w { + let copy_len = uv_size.min(planes[1].len()); + data[y_size..y_size + copy_len].copy_from_slice(&planes[1][..copy_len]); + } else { + for row in 0..uv_h { + let dst_off = y_size + row * chroma_w; + let src_off = row * uv_stride; + if src_off + chroma_w <= planes[1].len() && dst_off + chroma_w <= data.len() { + data[dst_off..dst_off + chroma_w] + .copy_from_slice(&planes[1][src_off..src_off + chroma_w]); + } + } + } + } + + data +} + +/// Write NV12 data from a StreamKit [`VideoFrame`] into a GBM frame's +/// write-mapping. +/// +/// If the source is I420, it is converted to NV12 on the fly (U/V planes +/// are interleaved into a single UV plane). +pub(super) fn write_nv12_to_mapping( + mapping: &dyn WriteMapping<'_>, + frame: &VideoFrame, + plane_pitches: &[usize], +) -> Result<(), String> { + let planes = mapping.get(); + if planes.is_empty() { + return Err("GBM mapping returned no planes".into()); + } + + let w = frame.width as usize; + let h = frame.height as usize; + let src = frame.data.as_ref().as_ref(); + + match frame.pixel_format { + PixelFormat::Nv12 => { + let y_size = w * h; + // NV12 UV row width: interleaved U/V pairs, matching VideoLayout::packed. + let chroma_w = w.div_ceil(2) * 2; + let uv_h = h.div_ceil(2); + let uv_size = chroma_w * uv_h; + + // Y plane. + let y_stride = plane_pitches.first().copied().unwrap_or(w); + { + let mut y_plane = planes[0].borrow_mut(); + if y_stride == w { + let n = y_size.min(y_plane.len()).min(src.len()); + y_plane[..n].copy_from_slice(&src[..n]); + } else { + for row in 0..h { + let s = row * w; + let d = row * y_stride; + if s + w > src.len() || d + w > y_plane.len() { + return Err(format!( + "NV12 Y row copy out of bounds: src[{}..{}] (len {}), dest[{}..{}] (len {})", + s, s + w, src.len(), d, d + w, y_plane.len() + )); + } + y_plane[d..d + w].copy_from_slice(&src[s..s + w]); + } + } + } + + // UV plane. + if planes.len() > 1 { + let uv_stride = plane_pitches.get(1).copied().unwrap_or(chroma_w); + let mut uv_plane = planes[1].borrow_mut(); + let src_uv = &src[y_size..]; + if uv_stride == chroma_w { + let n = uv_size.min(uv_plane.len()).min(src_uv.len()); + uv_plane[..n].copy_from_slice(&src_uv[..n]); + } else { + for row in 0..uv_h { + let s = row * chroma_w; + let d = row * uv_stride; + if s + chroma_w > src_uv.len() || d + chroma_w > uv_plane.len() { + return Err(format!( + "NV12 UV row copy out of bounds: src[{}..{}] (len {}), dest[{}..{}] (len {})", + s, s + chroma_w, src_uv.len(), d, d + chroma_w, uv_plane.len() + )); + } + uv_plane[d..d + chroma_w].copy_from_slice(&src_uv[s..s + chroma_w]); + } + } + } + }, + PixelFormat::I420 => { + // Convert I420 → NV12: Y stays the same, U and V are interleaved. + let y_size = w * h; + let uv_w = w.div_ceil(2); + let uv_h = h.div_ceil(2); + let u_plane_size = uv_w * uv_h; + + // Y plane. + let y_stride = plane_pitches.first().copied().unwrap_or(w); + { + let mut y_plane = planes[0].borrow_mut(); + if y_stride == w { + let n = y_size.min(y_plane.len()).min(src.len()); + y_plane[..n].copy_from_slice(&src[..n]); + } else { + for row in 0..h { + let s = row * w; + let d = row * y_stride; + if s + w > src.len() || d + w > y_plane.len() { + return Err(format!( + "I420 Y row copy out of bounds: src[{}..{}] (len {}), dest[{}..{}] (len {})", + s, s + w, src.len(), d, d + w, y_plane.len() + )); + } + y_plane[d..d + w].copy_from_slice(&src[s..s + w]); + } + } + } + + // UV plane — interleave U and V from I420 into NV12 UV. + if planes.len() > 1 { + let uv_stride = plane_pitches.get(1).copied().unwrap_or(uv_w * 2); + let mut uv_plane = planes[1].borrow_mut(); + for row in 0..uv_h { + for col in 0..uv_w { + let u_idx = y_size + row * uv_w + col; + let v_idx = y_size + u_plane_size + row * uv_w + col; + let dst_idx = row * uv_stride + col * 2; + if u_idx >= src.len() || v_idx >= src.len() || dst_idx + 1 >= uv_plane.len() + { + return Err(format!( + "I420 UV interleave out of bounds: u_idx={u_idx}, v_idx={v_idx} \ + (src len {}), dst_idx={dst_idx} (dest len {})", + src.len(), + uv_plane.len() + )); + } + uv_plane[dst_idx] = src[u_idx]; + uv_plane[dst_idx + 1] = src[v_idx]; + } + } + } + }, + _ => { + return Err(format!( + "VA-API AV1 encoder requires NV12 or I420 input, got {:?}", + frame.pixel_format + )); + }, + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +/// Configuration for the VA-API AV1 hardware decoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct VaapiAv1DecoderConfig { + /// Path to the DRM render device (e.g. `/dev/dri/renderD128`). + /// When `None`, auto-detects the first VA-API capable device. + pub render_device: Option, + + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, +} + +impl Default for VaapiAv1DecoderConfig { + fn default() -> Self { + Self { render_device: None, hw_accel: HwAccelMode::Auto } + } +} + +pub struct VaapiAv1DecoderNode { + config: VaapiAv1DecoderConfig, +} + +impl VaapiAv1DecoderNode { + #[allow(clippy::missing_errors_doc)] + pub fn new(config: VaapiAv1DecoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "VaapiAv1DecoderNode only supports hardware decoding; \ + use video::av1::decoder for CPU decode" + .into(), + )); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for VaapiAv1DecoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::Av1, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + })], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + async fn run(self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { + let node_name = context.output_sender.node_name().to_string(); + state_helpers::emit_initializing(&context.state_tx, &node_name); + + tracing::info!("VaapiAv1DecoderNode starting"); + let mut input_rx = context.take_input("in")?; + + let meter = global::meter("skit_nodes"); + let packets_processed_counter = + meter.u64_counter("vaapi_av1_decoder_packets_processed").build(); + let decode_duration_histogram = meter + .f64_histogram("vaapi_av1_decode_duration") + .with_boundaries(streamkit_core::metrics::HISTOGRAM_BOUNDARIES_CODEC_PACKET.to_vec()) + .build(); + + let (decode_tx, decode_rx) = + mpsc::channel::<(Bytes, Option)>(get_codec_channel_capacity()); + let (result_tx, mut result_rx) = + mpsc::channel::>(get_codec_channel_capacity()); + + let render_device = self.config.render_device.clone(); + let decode_task = tokio::task::spawn_blocking(move || { + vaapi_av1_decode_loop( + render_device.as_ref(), + decode_rx, + &result_tx, + &decode_duration_histogram, + ); + }); + + state_helpers::emit_running(&context.state_tx, &node_name); + + let mut stats_tracker = NodeStatsTracker::new(node_name.clone(), context.stats_tx.clone()); + let batch_size = context.batch_size; + + let decode_tx_clone = decode_tx.clone(); + let mut input_task = tokio::spawn(async move { + loop { + let Some(first_packet) = input_rx.recv().await else { + break; + }; + + let packet_batch = + packet_helpers::batch_packets_greedy(first_packet, &mut input_rx, batch_size); + + for packet in packet_batch { + if let Packet::Binary { data, metadata, .. } = packet { + if decode_tx_clone.send((data, metadata)).await.is_err() { + tracing::error!( + "VaapiAv1DecoderNode decode task has shut down unexpectedly" + ); + return; + } + } + } + } + tracing::info!("VaapiAv1DecoderNode input stream closed"); + }); + + crate::codec_utils::codec_forward_loop( + &mut context, + &mut result_rx, + &mut input_task, + decode_task, + decode_tx, + &packets_processed_counter, + &mut stats_tracker, + Packet::Video, + "VaapiAv1DecoderNode", + ) + .await; + + state_helpers::emit_stopped(&context.state_tx, &node_name, "input_closed"); + tracing::info!("VaapiAv1DecoderNode finished"); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Decoder — blocking decode loop +// --------------------------------------------------------------------------- + +/// Blocking decode loop running inside `spawn_blocking`. +/// +/// Opens the VA-API display and GBM device, creates the AV1 +/// `StatelessDecoder`, then delegates to the codec-agnostic +/// [`vaapi_decode_loop_body`]. +fn vaapi_av1_decode_loop( + render_device: Option<&String>, + mut decode_rx: mpsc::Receiver<(Bytes, Option)>, + result_tx: &mpsc::Sender>, + duration_histogram: &opentelemetry::metrics::Histogram, +) { + let (display, gbm, path) = match open_va_and_gbm(render_device) { + Ok(v) => v, + Err(e) => { + let _ = result_tx.blocking_send(Err(e)); + return; + }, + }; + tracing::info!(device = %path, "VA-API AV1 decoder opened display"); + + let mut decoder = match StatelessDecoder::>::new_vaapi( + display, + BlockingMode::Blocking, + ) { + Ok(d) => d, + Err(e) => { + let _ = + result_tx.blocking_send(Err(format!("failed to create VA-API AV1 decoder: {e}"))); + return; + }, + }; + + vaapi_decode_loop_body( + "AV1", + &mut decoder, + &gbm, + &mut decode_rx, + result_tx, + duration_histogram, + ); +} + +// --------------------------------------------------------------------------- +// Generic VA-API decode helpers (shared by AV1 + H.264) +// --------------------------------------------------------------------------- + +/// Codec-agnostic VA-API blocking decode loop body. +/// +/// Processes input packets from `decode_rx` through the provided +/// `StatelessVideoDecoder`, converting decoded GBM frames to StreamKit +/// [`VideoFrame`]s and sending them on `result_tx`. +/// +/// Callers handle codec-specific display/GBM/decoder initialisation, +/// then delegate here. `codec_name` is embedded in log messages +/// (e.g. `"AV1"`, `"H.264"`). +pub(super) fn vaapi_decode_loop_body( + codec_name: &str, + decoder: &mut D, + gbm: &Arc, + decode_rx: &mut mpsc::Receiver<(Bytes, Option)>, + result_tx: &mpsc::Sender>, + duration_histogram: &opentelemetry::metrics::Histogram, +) where + D: StatelessVideoDecoder, + D::Handle: DecodedHandle, +{ + let mut coded_width: u32 = 0; + let mut coded_height: u32 = 0; + + while let Some((data, metadata)) = decode_rx.blocking_recv() { + if result_tx.is_closed() { + return; + } + + let decode_start = Instant::now(); + let timestamp = metadata.as_ref().and_then(|m| m.timestamp_us).unwrap_or(0); + + // Feed bitstream to the decoder. The decoder may process it in + // multiple chunks and may require event handling between calls. + let mut offset = 0usize; + let bitstream = data.as_ref(); + let mut eagain_empty_retries: u32 = 0; + + while offset < bitstream.len() { + let gbm_ref = Arc::clone(gbm); + let cw = coded_width; + let ch = coded_height; + let mut alloc_cb = move || { + let res = CrosResolution { width: cw, height: ch }; + gbm_ref.clone().new_frame(nv12_fourcc(), res.clone(), res, GbmUsage::Decode).ok() + }; + + let mut made_progress = false; + + match decoder.decode(timestamp, &bitstream[offset..], &mut alloc_cb) { + Ok(bytes_consumed) => { + offset += bytes_consumed; + made_progress = true; + }, + Err(DecodeError::CheckEvents | DecodeError::NotEnoughOutputBuffers(_)) => { + // Process pending events / drain ready frames, then retry. + }, + Err(e) => { + tracing::error!(error = %e, "VA-API {} decode error", codec_name); + let _ = result_tx + .blocking_send(Err(format!("VA-API {codec_name} decode error: {e}"))); + break; + }, + } + + // Process all pending events (format changes + ready frames). + let (should_exit, had_events) = vaapi_drain_decoder_events( + codec_name, + decoder, + result_tx, + metadata.as_ref(), + &mut coded_width, + &mut coded_height, + ); + if should_exit { + return; + } + + if made_progress || had_events { + eagain_empty_retries = 0; + } else { + eagain_empty_retries += 1; + if eagain_empty_retries > MAX_EAGAIN_EMPTY_RETRIES { + tracing::error!( + "VA-API {} decoder stuck: no progress after {} retries", + codec_name, + MAX_EAGAIN_EMPTY_RETRIES, + ); + let _ = result_tx.blocking_send(Err(format!( + "VA-API {codec_name} decoder stuck in \ + CheckEvents/NotEnoughOutputBuffers loop" + ))); + break; + } + // Progressive backoff to avoid a tight spin-loop. + if eagain_empty_retries <= EAGAIN_YIELD_THRESHOLD { + std::thread::yield_now(); + } else { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + } + + duration_histogram.record(decode_start.elapsed().as_secs_f64(), &[]); + } + + // Flush remaining frames from the decoder. + if result_tx.is_closed() { + return; + } + if let Err(e) = decoder.flush() { + tracing::warn!(error = %e, "VA-API {} decoder flush failed", codec_name); + } + vaapi_drain_decoder_events( + codec_name, + decoder, + result_tx, + None, + &mut coded_width, + &mut coded_height, + ); +} + +/// Drain all pending events from a VA-API `StatelessVideoDecoder`. +/// +/// Returns `(should_exit, had_events)`: +/// - `should_exit`: the result channel is closed and the caller should return. +/// - `had_events`: at least one event (format change or frame) was processed. +/// +/// Codec-agnostic — used by both AV1 and H.264 VA-API decoders. +fn vaapi_drain_decoder_events( + codec_name: &str, + decoder: &mut D, + result_tx: &mpsc::Sender>, + metadata: Option<&PacketMetadata>, + coded_width: &mut u32, + coded_height: &mut u32, +) -> (bool, bool) +where + D: StatelessVideoDecoder, + D::Handle: DecodedHandle, +{ + let mut had_events = false; + while let Some(event) = decoder.next_event() { + had_events = true; + match event { + DecoderEvent::FormatChanged => { + if let Some(info) = decoder.stream_info() { + let dw = info.display_resolution.width; + let dh = info.display_resolution.height; + *coded_width = info.coded_resolution.width; + *coded_height = info.coded_resolution.height; + tracing::info!( + display_width = dw, + display_height = dh, + coded_width = *coded_width, + coded_height = *coded_height, + "VA-API {} decoder stream format changed", + codec_name, + ); + } + }, + DecoderEvent::FrameReady(handle) => { + if let Err(e) = handle.sync() { + tracing::error!(error = %e, "VA-API {} frame sync failed", codec_name); + continue; + } + + let display_res = handle.display_resolution(); + let frame_w = display_res.width; + let frame_h = display_res.height; + + let gbm_frame = handle.video_frame(); + let pitches = gbm_frame.get_plane_pitch(); + + // Extract NV12 data while the mapping is alive, then drop the + // mapping before `gbm_frame` to satisfy the borrow checker. + let nv12_data = { + let mapping = match gbm_frame.map() { + Ok(m) => m, + Err(e) => { + tracing::error!(error = %e, "failed to map decoded GBM frame"); + continue; + }, + }; + read_nv12_from_mapping(mapping.as_ref(), frame_w, frame_h, &pitches) + }; + + match VideoFrame::with_metadata( + frame_w, + frame_h, + PixelFormat::Nv12, + nv12_data, + metadata.cloned(), + ) { + Ok(frame) => { + if result_tx.blocking_send(Ok(frame)).is_err() { + return (true, had_events); + } + }, + Err(e) => { + tracing::error!( + error = %e, + "failed to construct VideoFrame from decoded data" + ); + }, + } + }, + } + } + (false, had_events) +} + +// --------------------------------------------------------------------------- +// Encoder +// --------------------------------------------------------------------------- + +/// Configuration for the VA-API AV1 hardware encoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct VaapiAv1EncoderConfig { + /// Path to the DRM render device (e.g. `/dev/dri/renderD128`). + /// When `None`, auto-detects the first VA-API capable device. + pub render_device: Option, + + /// Constant quality parameter (QP). Lower values produce higher quality + /// at the cost of larger bitstream. Range depends on the driver; typical + /// range is 0–255, default 128. + /// + /// Note: VA-API AV1 encoding via cros-codecs currently supports only the + /// `ConstantQuality` rate control mode, not `ConstantBitrate`. + pub quality: u32, + + /// Target framerate in frames per second (used for rate control hints). + pub framerate: u32, + + /// Use low-power encoding mode if the driver supports it. + /// Low-power mode uses the GPU's fixed-function encoder (if available) + /// rather than shader-based encoding, typically offering lower latency + /// at reduced quality flexibility. + pub low_power: bool, + + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, +} + +impl Default for VaapiAv1EncoderConfig { + fn default() -> Self { + Self { + render_device: None, + quality: DEFAULT_QUALITY, + framerate: DEFAULT_FRAMERATE, + low_power: false, + hw_accel: HwAccelMode::Auto, + } + } +} + +pub struct VaapiAv1EncoderNode { + config: VaapiAv1EncoderConfig, +} + +impl VaapiAv1EncoderNode { + #[allow(clippy::missing_errors_doc)] + pub fn new(config: VaapiAv1EncoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "VaapiAv1EncoderNode only supports hardware encoding; \ + use video::av1::encoder for CPU encode" + .into(), + )); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for VaapiAv1EncoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![ + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::I420, + }), + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + ], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::Av1, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + fn content_type(&self) -> Option { + Some(AV1_CONTENT_TYPE.to_string()) + } + + async fn run(self: Box, context: NodeContext) -> Result<(), StreamKitError> { + encoder_trait::run_encoder(*self, context).await + } +} + +impl EncoderNodeRunner for VaapiAv1EncoderNode { + const CONTENT_TYPE: &'static str = AV1_CONTENT_TYPE; + const NODE_LABEL: &'static str = "VaapiAv1EncoderNode"; + const PACKETS_COUNTER_NAME: &'static str = "vaapi_av1_encoder_packets_processed"; + const DURATION_HISTOGRAM_NAME: &'static str = "vaapi_av1_encode_duration"; + + fn spawn_codec_task( + self, + encode_rx: mpsc::Receiver<(VideoFrame, Option)>, + result_tx: mpsc::Sender>, + duration_histogram: opentelemetry::metrics::Histogram, + ) -> tokio::task::JoinHandle<()> { + encoder_trait::spawn_standard_encode_task::( + self.config, + encode_rx, + result_tx, + duration_histogram, + ) + } +} + +// --------------------------------------------------------------------------- +// Encoder — internal codec wrapper +// --------------------------------------------------------------------------- + +/// Type alias for the VA-API AV1 encoder using GBM-backed surfaces. +/// +/// Uses the standard cros-codecs path with `GbmVideoFrame` and +/// `GBM_BO_USE_HW_VIDEO_ENCODER`. AV1 hardware encoding requires GPU +/// support (e.g. Intel DG2 / Arc); on hardware without AV1 encode support +/// (e.g. Tiger Lake) the encoder will fail at creation time. +type CrosVaapiAv1Encoder = StatelessEncoder< + cros_codecs::encoder::av1::AV1, + GbmVideoFrame, + cros_codecs::backend::vaapi::encoder::VaapiBackend< + GbmExternalBufferDescriptor, + libva::Surface, + >, +>; + +/// Internal encoder state wrapping the cros-codecs `StatelessEncoder`. +/// +/// `!Send` due to internal `Rc` — lives entirely inside +/// a `spawn_blocking` thread, matching the pattern in `av1.rs`. +struct VaapiAv1Encoder { + encoder: CrosVaapiAv1Encoder, + display: Rc, + gbm: Arc, + width: u32, + height: u32, + coded_width: u32, + coded_height: u32, + frame_count: u64, +} + +impl StandardVideoEncoder for VaapiAv1Encoder { + type Config = VaapiAv1EncoderConfig; + const CODEC_NAME: &'static str = "VA-API AV1"; + + fn new_encoder(width: u32, height: u32, config: &Self::Config) -> Result { + let (display, gbm, path) = open_va_and_gbm(config.render_device.as_ref())?; + tracing::info!(device = %path, width, height, "VA-API AV1 encoder opening"); + + let coded_width = align_up_u32(width, AV1_SB_SIZE); + let coded_height = align_up_u32(height, AV1_SB_SIZE); + + let cros_config = CrosEncoderConfig { + profile: Av1Profile::Profile0, + bit_depth: cros_codecs::codec::av1::parser::BitDepth::Depth8, + resolution: CrosResolution { width: coded_width, height: coded_height }, + pred_structure: PredictionStructure::LowDelay { limit: 1024 }, + initial_tunings: Tunings { + rate_control: RateControl::ConstantQuality(config.quality), + framerate: config.framerate, + min_quality: 0, + max_quality: 255, + }, + }; + + let encoder = CrosVaapiAv1Encoder::new_vaapi( + Rc::clone(&display), + cros_config, + nv12_fourcc(), + CrosResolution { width: coded_width, height: coded_height }, + config.low_power, + BlockingMode::Blocking, + ) + .map_err(|e| format!("failed to create VA-API AV1 encoder: {e}"))?; + + tracing::info!( + device = %path, + width, + height, + coded_width, + coded_height, + quality = config.quality, + "VA-API AV1 encoder created" + ); + + Ok(Self { encoder, display, gbm, width, height, coded_width, coded_height, frame_count: 0 }) + } + + fn encode( + &mut self, + frame: &VideoFrame, + metadata: Option, + ) -> Result, String> { + if frame.pixel_format == PixelFormat::Rgba8 { + return Err("VA-API AV1 encoder requires NV12 or I420 input; \ + insert a video::pixel_convert node upstream" + .into()); + } + + // Allocate a GBM frame and write NV12 data into it. + let coded_res = CrosResolution { width: self.coded_width, height: self.coded_height }; + let visible_res = CrosResolution { width: self.width, height: self.height }; + let mut gbm_frame = Arc::clone(&self.gbm) + .new_frame(nv12_fourcc(), visible_res, coded_res, GbmUsage::Encode) + .map_err(|e| format!("failed to allocate GBM frame for encoding: {e}"))?; + + let pitches_for_write = gbm_frame.get_plane_pitch(); + { + let mapping = gbm_frame + .map_mut() + .map_err(|e| format!("failed to map GBM frame for writing: {e}"))?; + write_nv12_to_mapping(mapping.as_ref(), frame, &pitches_for_write)?; + } + + let is_keyframe = metadata.as_ref().and_then(|m| m.keyframe).unwrap_or(false); + let timestamp = metadata.as_ref().and_then(|m| m.timestamp_us).unwrap_or(self.frame_count); + + let plane_pitches = gbm_frame.get_plane_pitch(); + let plane_sizes = gbm_frame.get_plane_size(); + let mut offset = 0usize; + let mut planes = Vec::new(); + for i in 0..gbm_frame.num_planes() { + planes.push(PlaneLayout { buffer_index: 0, offset, stride: plane_pitches[i] }); + offset += plane_sizes[i]; + } + + let frame_layout = FrameLayout { + format: (nv12_fourcc(), 0), // DRM_FORMAT_MOD_LINEAR + size: coded_res, + planes, + }; + + let cros_meta = + CrosFrameMetadata { timestamp, layout: frame_layout, force_keyframe: is_keyframe }; + + self.encoder + .encode(cros_meta, gbm_frame) + .map_err(|e| format!("VA-API AV1 encode error: {e}"))?; + + self.frame_count += 1; + + // Poll for all available encoded output. + let mut packets = Vec::new(); + loop { + match self.encoder.poll() { + Ok(Some(coded)) => { + let out_meta = merge_av1_keyframe_metadata(metadata.clone(), &coded.bitstream); + packets.push(EncodedPacket { + data: Bytes::from(coded.bitstream), + metadata: out_meta, + }); + }, + Ok(None) => break, + Err(e) => return Err(format!("VA-API AV1 encoder poll error: {e}")), + } + } + + Ok(packets) + } + + fn flush_encoder(&mut self) -> Result, String> { + self.encoder.drain().map_err(|e| format!("VA-API AV1 encoder drain error: {e}"))?; + + let mut packets = Vec::new(); + loop { + match self.encoder.poll() { + Ok(Some(coded)) => { + let out_meta = merge_av1_keyframe_metadata(None, &coded.bitstream); + packets.push(EncodedPacket { + data: Bytes::from(coded.bitstream), + metadata: out_meta, + }); + }, + Ok(None) => break, + Err(e) => return Err(format!("VA-API AV1 encoder poll error: {e}")), + } + } + + Ok(packets) + } + + fn flush_on_dimension_change() -> bool { + true + } +} + +// --------------------------------------------------------------------------- +// Keyframe detection +// --------------------------------------------------------------------------- + +/// Detect whether an AV1 bitstream produced by the encoder contains a +/// keyframe by scanning for a Frame OBU with `frame_type == KEY_FRAME`. +/// +/// AV1 OBU header format (§ 5.3.1): +/// byte 0: `obu_forbidden_bit(1) | obu_type(4) | extension_flag(1) | has_size_field(1) | reserved(1)` +/// +/// OBU types of interest: +/// - 6 = `OBU_FRAME` (contains frame header + tile group) +/// - 3 = `OBU_FRAME_HEADER` +/// +/// Frame header first byte (§ 5.9.2): +/// `show_existing_frame(1) | frame_type(2) | ...` +/// frame_type 0 = KEY_FRAME +fn av1_bitstream_is_keyframe(bitstream: &[u8]) -> bool { + let mut pos = 0; + while pos < bitstream.len() { + let header_byte = bitstream[pos]; + let obu_type = (header_byte >> 3) & 0x0F; + let extension_flag = (header_byte >> 2) & 1; + let has_size_field = (header_byte >> 1) & 1; + pos += 1; + + // Skip extension header if present. + if extension_flag == 1 { + if pos >= bitstream.len() { + break; + } + pos += 1; + } + + // Read OBU size (LEB128) if has_size_field is set. + let obu_size = if has_size_field == 1 { + let mut size: u64 = 0; + let mut shift = 0; + loop { + if pos >= bitstream.len() { + return false; + } + let b = bitstream[pos]; + pos += 1; + size |= u64::from(b & 0x7F) << shift; + if b & 0x80 == 0 { + break; + } + shift += 7; + if shift >= 56 { + return false; // malformed LEB128 + } + } + Some(size as usize) + } else { + None + }; + + // OBU_FRAME (6) or OBU_FRAME_HEADER (3): check frame_type. + if obu_type == 6 || obu_type == 3 { + if pos < bitstream.len() { + let frame_byte = bitstream[pos]; + let show_existing_frame = (frame_byte >> 7) & 1; + if show_existing_frame == 0 { + let frame_type = (frame_byte >> 5) & 0x03; + // frame_type 0 = KEY_FRAME + return frame_type == 0; + } + } + return false; + } + + // Skip over this OBU's payload. + if let Some(size) = obu_size { + pos += size; + } else { + // Without size field, we can't skip — assume rest is one OBU. + break; + } + } + false +} + +/// Build output metadata with the keyframe flag set from encoder output. +/// +/// Uses bitstream-level detection because cros-codecs' +/// `CodedBitstreamBuffer.metadata.force_keyframe` only reflects the +/// *caller's* request — not encoder-initiated periodic keyframes from +/// the `LowDelay` prediction structure. +fn merge_av1_keyframe_metadata( + metadata: Option, + bitstream: &[u8], +) -> Option { + let is_keyframe = av1_bitstream_is_keyframe(bitstream); + Some(match metadata { + Some(mut m) => { + m.keyframe = Some(is_keyframe); + m + }, + None => PacketMetadata { + timestamp_us: None, + duration_us: None, + sequence: None, + keyframe: Some(is_keyframe), + }, + }) +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +use schemars::schema_for; +use streamkit_core::registry::StaticPins; + +#[allow(clippy::expect_used, clippy::missing_panics_doc)] +pub fn register_vaapi_av1_nodes(registry: &mut NodeRegistry) { + let default_decoder = VaapiAv1DecoderNode::new(VaapiAv1DecoderConfig::default()) + .expect("default VA-API AV1 decoder config should be valid"); + registry.register_static_with_description( + "video::vaapi::av1_decoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(VaapiAv1DecoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(VaapiAv1DecoderConfig)) + .expect("VaapiAv1DecoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_decoder.input_pins(), outputs: default_decoder.output_pins() }, + vec![ + "video".to_string(), + "codecs".to_string(), + "av1".to_string(), + "hw".to_string(), + "vaapi".to_string(), + ], + false, + "Decodes AV1-compressed packets into raw NV12 video frames using VA-API \ + hardware acceleration. Requires a VA-API capable GPU (Intel Arc+, AMD, \ + or NVIDIA with nvidia-vaapi-driver).", + ); + + let default_encoder = VaapiAv1EncoderNode::new(VaapiAv1EncoderConfig::default()) + .expect("default VA-API AV1 encoder config should be valid"); + registry.register_static_with_description( + "video::vaapi::av1_encoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(VaapiAv1EncoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(VaapiAv1EncoderConfig)) + .expect("VaapiAv1EncoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_encoder.input_pins(), outputs: default_encoder.output_pins() }, + vec![ + "video".to_string(), + "codecs".to_string(), + "av1".to_string(), + "hw".to_string(), + "vaapi".to_string(), + ], + false, + "Encodes raw NV12/I420 video frames into AV1-compressed packets using VA-API \ + hardware acceleration. Uses constant-quality (CQP) rate control. Requires a \ + VA-API capable GPU with AV1 encode support (Intel Arc+, AMD).", + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_macros)] +mod tests { + use super::*; + use std::cell::RefCell; + + // ----------------------------------------------------------------------- + // Mock mapping types for unit-testing read/write helpers without a GPU. + // ----------------------------------------------------------------------- + + struct MockReadMapping<'a> { + planes: Vec<&'a [u8]>, + } + + impl<'a> ReadMapping<'a> for MockReadMapping<'a> { + fn get(&self) -> Vec<&[u8]> { + self.planes.clone() + } + } + + struct MockWriteMapping { + /// Raw pointer + length pairs, one per plane. + /// + /// Mirrors the upstream `GbmMapping` pattern: pointers are stored + /// once and used to construct `&mut` slices in `get()`. + planes: Vec<(*mut u8, usize)>, + } + + // SAFETY: Only used in single-threaded tests; the backing buffers + // outlive the mapping. + unsafe impl Send for MockWriteMapping {} + unsafe impl Sync for MockWriteMapping {} + + impl MockWriteMapping { + fn new(slices: Vec<&mut [u8]>) -> Self { + Self { planes: slices.into_iter().map(|s| (s.as_mut_ptr(), s.len())).collect() } + } + } + + impl<'a> WriteMapping<'a> for MockWriteMapping { + fn get(&self) -> Vec> { + // SAFETY: Each call produces non-overlapping plane slices from + // pointers captured at construction time. Callers must not + // hold references from a previous `get()` when calling again + // — the same contract the upstream `GbmMapping` relies on. + self.planes + .iter() + .map(|&(ptr, len)| { + RefCell::new(unsafe { std::slice::from_raw_parts_mut(ptr, len) }) + }) + .collect() + } + } + + // ----------------------------------------------------------------------- + // align_up_u32 + // ----------------------------------------------------------------------- + + #[test] + fn test_align_up_u32_already_aligned() { + assert_eq!(align_up_u32(64, 64), 64); + assert_eq!(align_up_u32(128, 64), 128); + } + + #[test] + fn test_align_up_u32_needs_alignment() { + assert_eq!(align_up_u32(65, 64), 128); + assert_eq!(align_up_u32(1, 64), 64); + assert_eq!(align_up_u32(100, 64), 128); + } + + #[test] + fn test_align_up_u32_alignment_one() { + assert_eq!(align_up_u32(42, 1), 42); + } + + // ----------------------------------------------------------------------- + // read_nv12_from_mapping — buffer size and content + // ----------------------------------------------------------------------- + + #[test] + fn test_read_nv12_even_dimensions() { + let w: u32 = 64; + let h: u32 = 48; + let y_size = (w * h) as usize; + let uv_h = h as usize / 2; + let chroma_w = w as usize; // even width: chroma_w == w + let uv_size = chroma_w * uv_h; + + let y_plane = vec![0xAA_u8; y_size]; + let uv_plane = vec![0x80_u8; uv_size]; + let mapping = MockReadMapping { planes: vec![&y_plane, &uv_plane] }; + let pitches = [w as usize, chroma_w]; + + let data = read_nv12_from_mapping(&mapping, w, h, &pitches); + + let layout = streamkit_core::types::VideoLayout::packed(w, h, PixelFormat::Nv12); + assert_eq!( + data.len(), + layout.total_bytes(), + "output buffer size must match VideoLayout::packed" + ); + assert!(data[..y_size].iter().all(|&b| b == 0xAA), "Y plane data mismatch"); + assert!(data[y_size..].iter().all(|&b| b == 0x80), "UV plane data mismatch"); + } + + #[test] + fn test_read_nv12_odd_width() { + // Odd width exercises the chroma_w = (w+1)/2*2 formula. + let w: u32 = 641; + let h: u32 = 480; + let y_size = (w * h) as usize; + let chroma_w = (w as usize + 1) / 2 * 2; // 642 + let uv_h = h as usize / 2; + let uv_size = chroma_w * uv_h; + + let y_plane = vec![0x10_u8; y_size]; + let uv_plane = vec![0x80_u8; uv_size]; + let mapping = MockReadMapping { planes: vec![&y_plane, &uv_plane] }; + let pitches = [w as usize, chroma_w]; + + let data = read_nv12_from_mapping(&mapping, w, h, &pitches); + + let layout = streamkit_core::types::VideoLayout::packed(w, h, PixelFormat::Nv12); + assert_eq!( + data.len(), + layout.total_bytes(), + "odd-width output buffer must match VideoLayout::packed (chroma_w={chroma_w})" + ); + } + + #[test] + fn test_read_nv12_odd_height() { + let w: u32 = 64; + let h: u32 = 49; // odd height + let y_size = (w * h) as usize; + let chroma_w = w as usize; + let uv_h = (h as usize + 1) / 2; // 25 + let uv_size = chroma_w * uv_h; + + let y_plane = vec![0x10_u8; y_size]; + let uv_plane = vec![0x80_u8; uv_size]; + let mapping = MockReadMapping { planes: vec![&y_plane, &uv_plane] }; + let pitches = [w as usize, chroma_w]; + + let data = read_nv12_from_mapping(&mapping, w, h, &pitches); + + let layout = streamkit_core::types::VideoLayout::packed(w, h, PixelFormat::Nv12); + assert_eq!( + data.len(), + layout.total_bytes(), + "odd-height output buffer must match VideoLayout::packed" + ); + } + + #[test] + fn test_read_nv12_with_stride() { + // Simulate a GBM surface with stride > width (e.g. 128-byte aligned). + let w: u32 = 100; + let h: u32 = 4; + let y_stride = 128_usize; // padded stride + let uv_stride = 128_usize; + let uv_h = 2_usize; + let chroma_w = (w as usize + 1) / 2 * 2; // 100 + + // Build Y plane with stride padding. + let mut y_plane = vec![0u8; y_stride * h as usize]; + for row in 0..h as usize { + for col in 0..w as usize { + y_plane[row * y_stride + col] = 0xAA; + } + } + + // Build UV plane with stride padding. + let mut uv_plane = vec![0u8; uv_stride * uv_h]; + for row in 0..uv_h { + for col in 0..chroma_w { + uv_plane[row * uv_stride + col] = 0x80; + } + } + + let mapping = MockReadMapping { planes: vec![&y_plane, &uv_plane] }; + let pitches = [y_stride, uv_stride]; + + let data = read_nv12_from_mapping(&mapping, w, h, &pitches); + + let layout = streamkit_core::types::VideoLayout::packed(w, h, PixelFormat::Nv12); + assert_eq!(data.len(), layout.total_bytes()); + + // Verify Y data is correctly de-strided. + let y_size = w as usize * h as usize; + assert!(data[..y_size].iter().all(|&b| b == 0xAA)); + // Verify UV data is correctly de-strided. + assert!(data[y_size..].iter().all(|&b| b == 0x80)); + } + + // ----------------------------------------------------------------------- + // read → VideoFrame::with_metadata roundtrip + // ----------------------------------------------------------------------- + + #[test] + fn test_read_nv12_produces_valid_video_frame() { + // The key invariant: read_nv12_from_mapping output must be accepted by + // VideoFrame::with_metadata, which validates against VideoLayout::packed. + for &(w, h) in &[(64, 48), (641, 480), (1920, 1080), (1921, 1081)] { + let y_size = (w * h) as usize; + let chroma_w = (w as usize + 1) / 2 * 2; + let uv_h = (h as usize + 1) / 2; + let uv_size = chroma_w * uv_h; + + let y_plane = vec![0x10_u8; y_size]; + let uv_plane = vec![0x80_u8; uv_size]; + let mapping = MockReadMapping { planes: vec![&y_plane, &uv_plane] }; + let pitches = [w as usize, chroma_w]; + + let data = read_nv12_from_mapping(&mapping, w, h, &pitches); + let result = VideoFrame::with_metadata(w, h, PixelFormat::Nv12, data, None); + assert!( + result.is_ok(), + "VideoFrame::with_metadata failed for {w}x{h}: {:?}", + result.err() + ); + } + } + + // ----------------------------------------------------------------------- + // write_nv12_to_mapping — NV12 source + // ----------------------------------------------------------------------- + + #[test] + fn test_write_nv12_even_dimensions() { + let w: u32 = 64; + let h: u32 = 48; + let frame = crate::test_utils::create_test_video_frame(w, h, PixelFormat::Nv12, 0xAA); + + let y_size = (w * h) as usize; + let chroma_w = (w as usize + 1) / 2 * 2; + let uv_h = (h as usize + 1) / 2; + + let mut y_buf = vec![0u8; y_size]; + let mut uv_buf = vec![0u8; chroma_w * uv_h]; + + let mapping = MockWriteMapping::new(vec![&mut y_buf, &mut uv_buf]); + let pitches = [w as usize, chroma_w]; + + let result = write_nv12_to_mapping(&mapping, &frame, &pitches); + assert!(result.is_ok(), "write_nv12_to_mapping failed: {:?}", result.err()); + + // Y plane should be filled with 0xAA. + assert!(y_buf.iter().all(|&b| b == 0xAA), "Y plane should contain frame data"); + } + + #[test] + fn test_write_nv12_odd_width() { + let w: u32 = 641; + let h: u32 = 480; + let frame = crate::test_utils::create_test_video_frame(w, h, PixelFormat::Nv12, 0x10); + + let y_size = (w * h) as usize; + let chroma_w = (w as usize + 1) / 2 * 2; // 642 + let uv_h = (h as usize + 1) / 2; + + let mut y_buf = vec![0u8; y_size]; + let mut uv_buf = vec![0u8; chroma_w * uv_h]; + + let mapping = MockWriteMapping::new(vec![&mut y_buf, &mut uv_buf]); + let pitches = [w as usize, chroma_w]; + + let result = write_nv12_to_mapping(&mapping, &frame, &pitches); + assert!( + result.is_ok(), + "write_nv12_to_mapping should handle odd width {w}: {:?}", + result.err() + ); + } + + // ----------------------------------------------------------------------- + // write_nv12_to_mapping — I420 → NV12 conversion + // ----------------------------------------------------------------------- + + #[test] + fn test_write_i420_to_nv12_conversion() { + let w: u32 = 64; + let h: u32 = 48; + let frame = crate::test_utils::create_test_video_frame(w, h, PixelFormat::I420, 0x10); + + let y_size = (w * h) as usize; + let chroma_w = (w as usize + 1) / 2 * 2; + let uv_h = (h as usize + 1) / 2; + + let mut y_buf = vec![0u8; y_size]; + let mut uv_buf = vec![0u8; chroma_w * uv_h]; + + let mapping = MockWriteMapping::new(vec![&mut y_buf, &mut uv_buf]); + let pitches = [w as usize, chroma_w]; + + let result = write_nv12_to_mapping(&mapping, &frame, &pitches); + assert!(result.is_ok(), "I420→NV12 conversion failed: {:?}", result.err()); + + // Y plane should have the fill value. + assert!(y_buf.iter().all(|&b| b == 0x10), "Y plane should contain I420 luma data"); + + // UV plane should have interleaved U/V values (128 for neutral chroma + // from create_test_video_frame). + let uv_w = w.div_ceil(2) as usize; + for row in 0..uv_h { + for col in 0..uv_w { + let idx = row * chroma_w + col * 2; + assert_eq!(uv_buf[idx], 128, "U value at row={row} col={col}"); + assert_eq!(uv_buf[idx + 1], 128, "V value at row={row} col={col}"); + } + } + } + + #[test] + fn test_write_i420_to_nv12_odd_width() { + // Odd width exercises the UV stride fallback path — the fix ensures + // the fallback uses `uv_w * 2` instead of `w` so rows don't misalign. + let w: u32 = 641; + let h: u32 = 480; + let frame = crate::test_utils::create_test_video_frame(w, h, PixelFormat::I420, 0x10); + + let y_size = (w * h) as usize; + let uv_w = w.div_ceil(2) as usize; // 321 + let chroma_w = uv_w * 2; // 642 + let uv_h = (h as usize + 1) / 2; + + let mut y_buf = vec![0u8; y_size]; + let mut uv_buf = vec![0u8; chroma_w * uv_h]; + + let mapping = MockWriteMapping::new(vec![&mut y_buf, &mut uv_buf]); + // Deliberately omit pitches to exercise the fallback. + let pitches: [usize; 0] = []; + + let result = write_nv12_to_mapping(&mapping, &frame, &pitches); + assert!(result.is_ok(), "I420→NV12 odd-width conversion failed: {:?}", result.err()); + + // Verify UV interleaving on the last row to catch misalignment. + let last_row = uv_h - 1; + for col in 0..uv_w { + let idx = last_row * chroma_w + col * 2; + assert_eq!(uv_buf[idx], 128, "U at last row col={col}"); + assert_eq!(uv_buf[idx + 1], 128, "V at last row col={col}"); + } + } + + // ----------------------------------------------------------------------- + // write_nv12_to_mapping — unsupported pixel format + // ----------------------------------------------------------------------- + + #[test] + fn test_write_unsupported_format_returns_error() { + let w: u32 = 64; + let h: u32 = 48; + let frame = crate::test_utils::create_test_video_frame(w, h, PixelFormat::Rgba8, 0xFF); + + let mut y_buf = vec![0u8; (w * h) as usize]; + let mut uv_buf = vec![0u8; (w as usize) * (h as usize / 2)]; + + let mapping = MockWriteMapping::new(vec![&mut y_buf, &mut uv_buf]); + let pitches = [w as usize, w as usize]; + + let result = write_nv12_to_mapping(&mapping, &frame, &pitches); + assert!(result.is_err(), "RGBA8 input should be rejected"); + assert!( + result.unwrap_err().contains("requires NV12 or I420"), + "error message should mention supported formats" + ); + } + + // ----------------------------------------------------------------------- + // NV12 read→write roundtrip + // ----------------------------------------------------------------------- + + #[test] + fn test_nv12_read_write_roundtrip() { + // Verify that data read from a mapping can be written back and + // produces identical plane content. + for &(w, h) in &[(64, 48), (640, 480), (641, 481)] { + let y_size = (w * h) as usize; + let chroma_w = (w as usize + 1) / 2 * 2; + let uv_h = (h as usize + 1) / 2; + let uv_size = chroma_w * uv_h; + + // Create source planes with deterministic data. + let y_src: Vec = (0..y_size).map(|i| (i % 256) as u8).collect(); + let uv_src: Vec = (0..uv_size).map(|i| ((i + 128) % 256) as u8).collect(); + + // Read from mapping. + let read_mapping = MockReadMapping { planes: vec![&y_src, &uv_src] }; + let pitches = [w as usize, chroma_w]; + let data = read_nv12_from_mapping(&read_mapping, w, h, &pitches); + + // Create a VideoFrame from the read data. + let frame = VideoFrame::with_metadata(w, h, PixelFormat::Nv12, data, None).unwrap(); + + // Write back to a new mapping. + let mut y_dst = vec![0u8; y_size]; + let mut uv_dst = vec![0u8; uv_size]; + let write_mapping = MockWriteMapping::new(vec![&mut y_dst, &mut uv_dst]); + write_nv12_to_mapping(&write_mapping, &frame, &pitches).unwrap(); + + assert_eq!(y_dst, y_src, "Y plane roundtrip failed for {w}x{h}"); + assert_eq!(uv_dst, uv_src, "UV plane roundtrip failed for {w}x{h}"); + } + } + + // ----------------------------------------------------------------------- + // resolve_render_device + // ----------------------------------------------------------------------- + + #[test] + fn test_resolve_render_device_with_configured() { + let configured = "/dev/dri/renderD129".to_string(); + let result = resolve_render_device(Some(&configured)); + assert_eq!(result, "/dev/dri/renderD129"); + } + + #[test] + fn test_resolve_render_device_fallback() { + // Without a configured device and without real hardware, falls back + // to default or auto-detected device. + let result = resolve_render_device(None); + assert!(!result.is_empty(), "should return a non-empty device path"); + } + + // ----------------------------------------------------------------------- + // GPU integration tests — encode/decode roundtrip + // + // These require a VA-API capable GPU. They are compiled with the `vaapi` + // feature but skip at runtime if no VA-API device is available. + // ----------------------------------------------------------------------- + + /// Check whether a usable VA-API display can be opened. + fn vaapi_available() -> bool { + let path = resolve_render_device(None); + libva::Display::open_drm_display(std::path::Path::new(&path)).is_ok() + } + + /// Check whether the VA-API driver supports AV1 *encoding*. + /// + /// NVIDIA's community `nvidia-vaapi-driver` only supports decode, so + /// encode tests must be skipped on NVIDIA GPUs to avoid false failures. + fn vaapi_av1_encode_available() -> bool { + if !vaapi_available() { + return false; + } + // Try to create the encoder — this probes for AV1 encode entrypoints + // and will fail on drivers that only support decode (e.g. NVIDIA). + VaapiAv1Encoder::new_encoder(64, 64, &VaapiAv1EncoderConfig::default()).is_ok() + } + + /// Encoder + Decoder roundtrip: encode 5 NV12 frames, decode them back, + /// verify dimensions and pixel format. + #[tokio::test] + async fn test_vaapi_av1_encode_decode_roundtrip() { + if !vaapi_av1_encode_available() { + eprintln!("SKIP: no VA-API AV1 encode support available"); + return; + } + + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, + create_test_context, create_test_video_frame, + }; + use std::borrow::Cow; + use std::collections::HashMap; + + // --- Encode --- + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder_config = VaapiAv1EncoderConfig { + render_device: None, + hw_accel: HwAccelMode::Auto, + quality: 200, // fast, lower quality for test speed + framerate: 30, + low_power: false, + }; + let encoder = VaapiAv1EncoderNode::new(encoder_config).unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + for index in 0_u64..5 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(1_000 + 33_333 * index), + duration_us: Some(33_333), + sequence: Some(index), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "VA-API AV1 encoder produced no packets"); + + // --- Decode --- + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = VaapiAv1DecoderNode::new(VaapiAv1DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + if let Packet::Binary { data, metadata, .. } = packet { + dec_input_tx + .send(Packet::Binary { + data, + content_type: Some(Cow::Borrowed(AV1_CONTENT_TYPE)), + metadata, + }) + .await + .unwrap(); + } + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "VA-API AV1 decoder produced no frames"); + + for packet in decoded_packets { + match packet { + Packet::Video(frame) => { + assert_eq!(frame.width, 64); + assert_eq!(frame.height, 64); + assert_eq!(frame.pixel_format, PixelFormat::Nv12); + assert!(!frame.data().is_empty(), "Decoded frame should have data"); + }, + _ => panic!("Expected Video packet from VA-API AV1 decoder"), + } + } + } + + /// Verify decoded frames preserve metadata from input packets. + #[tokio::test] + async fn test_vaapi_av1_metadata_propagation() { + if !vaapi_av1_encode_available() { + eprintln!("SKIP: no VA-API AV1 encode support available"); + return; + } + + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, + create_test_context, create_test_video_frame, + }; + use std::borrow::Cow; + use std::collections::HashMap; + + // --- Encode --- + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder = VaapiAv1EncoderNode::new(VaapiAv1EncoderConfig { + render_device: None, + hw_accel: HwAccelMode::Auto, + quality: 200, + framerate: 30, + low_power: false, + }) + .unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + let timestamps: Vec = vec![1_000, 34_333, 67_666]; + for (i, &ts) in timestamps.iter().enumerate() { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(ts), + duration_us: Some(33_333), + sequence: Some(i as u64), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty()); + + // --- Decode and verify metadata --- + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = VaapiAv1DecoderNode::new(VaapiAv1DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + if let Packet::Binary { data, metadata, .. } = packet { + dec_input_tx + .send(Packet::Binary { + data, + content_type: Some(Cow::Borrowed(AV1_CONTENT_TYPE)), + metadata, + }) + .await + .unwrap(); + } + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "Decoder should produce at least one frame"); + + for (i, packet) in decoded_packets.iter().enumerate() { + match packet { + Packet::Video(frame) => { + assert!(frame.metadata.is_some(), "Decoded frame {i} should have metadata"); + }, + _ => panic!("Expected Video packet from VA-API AV1 decoder"), + } + } + } + + /// Encode I420 input frames and verify the encoder accepts them + /// (exercises the I420→NV12 conversion path). + #[tokio::test] + async fn test_vaapi_av1_encode_i420_input() { + if !vaapi_av1_encode_available() { + eprintln!("SKIP: no VA-API AV1 encode support available"); + return; + } + + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, + create_test_context, create_test_video_frame, + }; + use std::collections::HashMap; + + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder = VaapiAv1EncoderNode::new(VaapiAv1EncoderConfig { + render_device: None, + hw_accel: HwAccelMode::Auto, + quality: 200, + framerate: 30, + low_power: false, + }) + .unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + for index in 0_u64..3 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::I420, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * index), + duration_us: Some(33_333), + sequence: Some(index), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!( + !encoded_packets.is_empty(), + "VA-API AV1 encoder should accept I420 input and produce packets" + ); + } + + /// Verify that encoding at a non-superblock-aligned resolution (e.g. + /// 1280×720, which aligns to coded 1280×768) and decoding back produces + /// frames with the original display resolution, NOT the coded resolution. + /// + /// This catches the issue described in #292: if `cros-codecs` does not set + /// `render_and_frame_size_different=1` with the original display resolution + /// in the AV1 sequence header, decoded output will include visible black + /// padding bars from the superblock alignment. + #[tokio::test] + async fn test_vaapi_av1_resolution_padding_not_leaked() { + if !vaapi_av1_encode_available() { + eprintln!("SKIP: no VA-API AV1 encode support available"); + return; + } + + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, + create_test_context, create_test_video_frame, + }; + use std::borrow::Cow; + use std::collections::HashMap; + + let display_w: u32 = 1280; + let display_h: u32 = 720; + let coded_h = align_up_u32(display_h, AV1_SB_SIZE); // 768 + + assert_ne!( + display_h, coded_h, + "test requires a height that needs superblock alignment padding" + ); + + // --- Encode --- + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder = VaapiAv1EncoderNode::new(VaapiAv1EncoderConfig { + render_device: None, + hw_accel: HwAccelMode::Auto, + quality: 200, + framerate: 30, + low_power: false, + }) + .unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + // Send a single solid-color NV12 frame at 1280×720. + let mut frame = create_test_video_frame(display_w, display_h, PixelFormat::Nv12, 0x40); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(0), + duration_us: Some(33_333), + sequence: Some(0), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "VA-API AV1 encoder produced no packets"); + + // --- Decode --- + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = VaapiAv1DecoderNode::new(VaapiAv1DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + if let Packet::Binary { data, metadata, .. } = packet { + dec_input_tx + .send(Packet::Binary { + data, + content_type: Some(Cow::Borrowed(AV1_CONTENT_TYPE)), + metadata, + }) + .await + .unwrap(); + } + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "VA-API AV1 decoder produced no frames"); + + for (i, packet) in decoded_packets.iter().enumerate() { + match packet { + Packet::Video(frame) => { + assert_eq!( + frame.width, display_w, + "decoded frame {i} width should be display width {display_w}, got {}", + frame.width + ); + assert_eq!( + frame.height, display_h, + "decoded frame {i} height should be display height {display_h}, \ + got {} (coded_height={coded_h}). This indicates the AV1 sequence \ + header is missing render_and_frame_size_different=1 with the \ + original display resolution — see issue #292.", + frame.height + ); + assert_eq!(frame.pixel_format, PixelFormat::Nv12); + }, + _ => panic!("Expected Video packet from VA-API AV1 decoder"), + } + } + } + + /// Verify ForceCpu mode returns an error (VA-API is HW-only). + #[test] + fn test_vaapi_force_cpu_returns_error() { + let decoder_config = + VaapiAv1DecoderConfig { render_device: None, hw_accel: HwAccelMode::ForceCpu }; + let result = VaapiAv1DecoderNode::new(decoder_config); + assert!(result.is_err(), "ForceCpu should be rejected for VA-API decoder"); + + let encoder_config = VaapiAv1EncoderConfig { + render_device: None, + hw_accel: HwAccelMode::ForceCpu, + quality: DEFAULT_QUALITY, + framerate: DEFAULT_FRAMERATE, + low_power: false, + }; + let result = VaapiAv1EncoderNode::new(encoder_config); + assert!(result.is_err(), "ForceCpu should be rejected for VA-API encoder"); + } + + // ----------------------------------------------------------------------- + // deny_unknown_fields + // ----------------------------------------------------------------------- + + #[test] + fn test_deny_unknown_fields_decoder() { + let json = r#"{"render_device":null,"hw_accel":"auto","bogus":1}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "Unknown fields should be rejected"); + } + + #[test] + fn test_deny_unknown_fields_encoder() { + let json = r#"{"quality":128,"unknown_key":"oops"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "Unknown fields should be rejected"); + } + + // ── Registration test ──────────────────────────────────────────────── + + #[test] + fn test_node_registration() { + let mut registry = NodeRegistry::new(); + register_vaapi_av1_nodes(&mut registry); + + assert!( + registry.create_node("video::vaapi::av1_decoder", None).is_ok(), + "VA-API AV1 decoder should be registered" + ); + assert!( + registry.create_node("video::vaapi::av1_encoder", None).is_ok(), + "VA-API AV1 encoder should be registered" + ); + } + + // ── Keyframe detection unit tests ──────────────────────────────── + + #[test] + fn test_av1_keyframe_detection_key_frame() { + // Minimal AV1 bitstream: Temporal Delimiter OBU + Sequence Header + // OBU + Frame OBU with frame_type = KEY_FRAME (0). + // + // OBU header byte: obu_type(4) << 3 | has_size_field(1) << 1 + // Temporal Delimiter (type 2): 0b_0_0010_0_1_0 = 0x12 + // Sequence Header (type 1): 0b_0_0001_0_1_0 = 0x0A + // Frame OBU (type 6): 0b_0_0110_0_1_0 = 0x32 + + let bitstream: Vec = vec![ + 0x12, + 0x00, // Temporal Delimiter OBU, size=0 + 0x0A, + 0x01, + 0x00, // Sequence Header OBU, size=1, dummy payload + 0x32, + 0x02, // Frame OBU, size=2 + 0b_0_00_00000, // show_existing_frame=0, frame_type=0 (KEY_FRAME), ... + 0x00, // dummy tile data + ]; + assert!(av1_bitstream_is_keyframe(&bitstream)); + } + + #[test] + fn test_av1_keyframe_detection_inter_frame() { + // Frame OBU with frame_type = INTER_FRAME (1). + let bitstream: Vec = vec![ + 0x32, + 0x02, // Frame OBU, size=2 + 0b_0_01_00000, // show_existing_frame=0, frame_type=1 (INTER_FRAME) + 0x00, + ]; + assert!(!av1_bitstream_is_keyframe(&bitstream)); + } + + #[test] + fn test_av1_keyframe_detection_empty() { + assert!(!av1_bitstream_is_keyframe(&[])); + } + + #[test] + fn test_av1_keyframe_detection_show_existing_frame() { + // Frame OBU with show_existing_frame=1 — not a keyframe. + let bitstream: Vec = vec![ + 0x32, + 0x02, // Frame OBU, size=2 + 0b_1_00_00000, // show_existing_frame=1 + 0x00, + ]; + assert!(!av1_bitstream_is_keyframe(&bitstream)); + } + + #[test] + fn test_merge_av1_keyframe_metadata_with_existing() { + let meta = PacketMetadata { + timestamp_us: Some(42_000), + duration_us: Some(33_333), + sequence: Some(7), + keyframe: None, + }; + // KEY_FRAME bitstream + let bitstream: Vec = vec![0x32, 0x02, 0b_0_00_00000, 0x00]; + let result = merge_av1_keyframe_metadata(Some(meta), &bitstream).unwrap(); + assert_eq!(result.keyframe, Some(true)); + assert_eq!(result.timestamp_us, Some(42_000)); + assert_eq!(result.sequence, Some(7)); + } + + #[test] + fn test_merge_av1_keyframe_metadata_without_existing() { + // INTER_FRAME bitstream + let bitstream: Vec = vec![0x32, 0x02, 0b_0_01_00000, 0x00]; + let result = merge_av1_keyframe_metadata(None, &bitstream).unwrap(); + assert_eq!(result.keyframe, Some(false)); + assert!(result.timestamp_us.is_none()); + } +} diff --git a/crates/nodes/src/video/vaapi_h264.rs b/crates/nodes/src/video/vaapi_h264.rs new file mode 100644 index 00000000..86f1211b --- /dev/null +++ b/crates/nodes/src/video/vaapi_h264.rs @@ -0,0 +1,886 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! VA-API HW-accelerated H.264 encoder and decoder nodes. +//! +//! **Decoder** — uses the [`cros-codecs`](https://crates.io/crates/cros-codecs) +//! `StatelessDecoder` with GBM-backed surfaces. This works on all VA-API drivers +//! because GBM allocation for *decoding* (`GBM_BO_USE_HW_VIDEO_DECODER`) is +//! universally supported. +//! +//! **Encoder** — uses a custom VA-API shim ([`super::vaapi_h264_enc::VaH264Encoder`]) +//! that drives `libva` directly. Input frames are uploaded via the VA-API Image +//! API (`vaCreateImage`/`vaPutImage`) instead of GBM, which avoids the +//! `GBM_BO_USE_HW_VIDEO_ENCODER` flag that Mesa's iris driver rejects for NV12 +//! on some hardware (e.g. Intel Tiger Lake with Mesa ≤ 23.x). +//! +//! # Nodes +//! +//! - [`VaapiH264DecoderNode`] — decodes H.264 NAL packets to NV12 [`VideoFrame`]s +//! - [`VaapiH264EncoderNode`] — encodes NV12/I420 [`VideoFrame`]s to H.264 packets +//! +//! Both perform runtime capability detection: if no VA-API device is found (or +//! H.264 is not supported), the codec task returns an error so the pipeline can +//! fall back to a CPU codec (OpenH264). +//! +//! # Feature gate +//! +//! Requires `vaapi` Cargo feature and `libva-dev` + `libgbm-dev` system packages. +//! +//! # Platform support +//! +//! - **Intel**: H.264 encode + decode on all modern Intel GPUs (Sandy Bridge+). +//! - **AMD**: H.264 encode + decode via Mesa RadeonSI VA-API. +//! - **NVIDIA**: Decode only via community `nvidia-vaapi-driver` (no VA-API encoding). + +use std::sync::Arc; +use std::time::Instant; + +use async_trait::async_trait; +use bytes::Bytes; +use opentelemetry::global; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use streamkit_core::stats::NodeStatsTracker; +use streamkit_core::types::{ + EncodedVideoFormat, Packet, PacketMetadata, PacketType, PixelFormat, RawVideoFormat, + VideoCodec, VideoFrame, +}; +use streamkit_core::{ + config_helpers, get_codec_channel_capacity, packet_helpers, state_helpers, InputPin, + NodeContext, NodeRegistry, OutputPin, PinCardinality, ProcessorNode, StreamKitError, +}; +use tokio::sync::mpsc; + +// cros-codecs — used only for the H.264 *decoder* (GBM path). +use cros_codecs::backend::vaapi::decoder::VaapiBackend as VaapiDecBackend; +use cros_codecs::decoder::stateless::h264::H264; +use cros_codecs::decoder::stateless::StatelessDecoder; +use cros_codecs::decoder::BlockingMode; +use cros_codecs::libva; +use cros_codecs::video_frame::gbm_video_frame::GbmVideoFrame; + +// Custom VA-API H.264 encoder shim — drives libva directly, bypasses GBM. +use super::vaapi_h264_enc::{H264EncConfig, VaH264Encoder}; + +use super::encoder_trait::{self, EncodedPacket, EncoderNodeRunner, StandardVideoEncoder}; +use super::HwAccelMode; +use super::H264_CONTENT_TYPE; + +// Re-use helpers from the VA-API AV1 module — codec-agnostic VA-API routines. +use super::vaapi_av1::{ + nv12_fourcc, open_va_and_gbm, open_va_display, read_nv12_from_mapping, vaapi_decode_loop_body, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// H.264 macroblock size — coded resolution must be aligned to this. +const H264_MB_SIZE: u32 = 16; + +/// Default constant-quality parameter for H.264 (0–51 QP scale). +const DEFAULT_QUALITY: u32 = 26; + +/// Default framerate for rate-control hints. +const DEFAULT_FRAMERATE: u32 = 30; + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +/// Configuration for the VA-API H.264 hardware decoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct VaapiH264DecoderConfig { + /// Path to the DRM render device (e.g. `/dev/dri/renderD128`). + /// When `None`, auto-detects the first VA-API capable device. + pub render_device: Option, + + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, +} + +impl Default for VaapiH264DecoderConfig { + fn default() -> Self { + Self { render_device: None, hw_accel: HwAccelMode::Auto } + } +} + +pub struct VaapiH264DecoderNode { + config: VaapiH264DecoderConfig, +} + +impl VaapiH264DecoderNode { + #[allow(clippy::missing_errors_doc)] + pub fn new(config: VaapiH264DecoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "VaapiH264DecoderNode only supports hardware decoding; \ + no CPU H.264 decoder is currently available" + .into(), + )); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for VaapiH264DecoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::H264, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + })], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + async fn run(self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { + let node_name = context.output_sender.node_name().to_string(); + state_helpers::emit_initializing(&context.state_tx, &node_name); + + tracing::info!("VaapiH264DecoderNode starting"); + let mut input_rx = context.take_input("in")?; + + let meter = global::meter("skit_nodes"); + let packets_processed_counter = + meter.u64_counter("vaapi_h264_decoder_packets_processed").build(); + let decode_duration_histogram = meter + .f64_histogram("vaapi_h264_decode_duration") + .with_boundaries(streamkit_core::metrics::HISTOGRAM_BOUNDARIES_CODEC_PACKET.to_vec()) + .build(); + + let (decode_tx, decode_rx) = + mpsc::channel::<(Bytes, Option)>(get_codec_channel_capacity()); + let (result_tx, mut result_rx) = + mpsc::channel::>(get_codec_channel_capacity()); + + let render_device = self.config.render_device.clone(); + let decode_task = tokio::task::spawn_blocking(move || { + vaapi_h264_decode_loop( + render_device.as_ref(), + decode_rx, + &result_tx, + &decode_duration_histogram, + ); + }); + + state_helpers::emit_running(&context.state_tx, &node_name); + + let mut stats_tracker = NodeStatsTracker::new(node_name.clone(), context.stats_tx.clone()); + let batch_size = context.batch_size; + + let decode_tx_clone = decode_tx.clone(); + let mut input_task = tokio::spawn(async move { + loop { + let Some(first_packet) = input_rx.recv().await else { + break; + }; + + let packet_batch = + packet_helpers::batch_packets_greedy(first_packet, &mut input_rx, batch_size); + + for packet in packet_batch { + if let Packet::Binary { data, metadata, .. } = packet { + if decode_tx_clone.send((data, metadata)).await.is_err() { + tracing::error!( + "VaapiH264DecoderNode decode task has shut down unexpectedly" + ); + return; + } + } + } + } + tracing::info!("VaapiH264DecoderNode input stream closed"); + }); + + crate::codec_utils::codec_forward_loop( + &mut context, + &mut result_rx, + &mut input_task, + decode_task, + decode_tx, + &packets_processed_counter, + &mut stats_tracker, + Packet::Video, + "VaapiH264DecoderNode", + ) + .await; + + state_helpers::emit_stopped(&context.state_tx, &node_name, "input_closed"); + tracing::info!("VaapiH264DecoderNode finished"); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Decoder — blocking decode loop +// --------------------------------------------------------------------------- + +/// Blocking decode loop running inside `spawn_blocking`. +/// +/// Opens the VA-API display and GBM device, creates the H.264 +/// `StatelessDecoder`, then delegates to the codec-agnostic +/// [`vaapi_decode_loop_body`]. +fn vaapi_h264_decode_loop( + render_device: Option<&String>, + mut decode_rx: mpsc::Receiver<(Bytes, Option)>, + result_tx: &mpsc::Sender>, + duration_histogram: &opentelemetry::metrics::Histogram, +) { + let (display, gbm, path) = match open_va_and_gbm(render_device) { + Ok(v) => v, + Err(e) => { + let _ = result_tx.blocking_send(Err(e)); + return; + }, + }; + tracing::info!(device = %path, "VA-API H.264 decoder opened display"); + + let mut decoder = match StatelessDecoder::>::new_vaapi( + display, + BlockingMode::Blocking, + ) { + Ok(d) => d, + Err(e) => { + let _ = + result_tx.blocking_send(Err(format!("failed to create VA-API H.264 decoder: {e}"))); + return; + }, + }; + + vaapi_decode_loop_body( + "H.264", + &mut decoder, + &gbm, + &mut decode_rx, + result_tx, + duration_histogram, + ); +} + +// --------------------------------------------------------------------------- +// Encoder +// --------------------------------------------------------------------------- + +/// Configuration for the VA-API H.264 hardware encoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct VaapiH264EncoderConfig { + /// Path to the DRM render device (e.g. `/dev/dri/renderD128`). + /// When `None`, auto-detects the first VA-API capable device. + pub render_device: Option, + + /// Constant quality parameter (QP). Lower values produce higher quality + /// at the cost of larger bitstream. H.264 QP range is 0–51, default 26. + pub quality: u32, + + /// Target framerate in frames per second (used for rate control hints). + pub framerate: u32, + + /// Use low-power encoding mode if the driver supports it. + /// Low-power mode uses the GPU's fixed-function encoder (if available) + /// rather than shader-based encoding, typically offering lower latency + /// at reduced quality flexibility. + pub low_power: bool, + + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, +} + +impl Default for VaapiH264EncoderConfig { + fn default() -> Self { + Self { + render_device: None, + quality: DEFAULT_QUALITY, + framerate: DEFAULT_FRAMERATE, + low_power: false, + hw_accel: HwAccelMode::Auto, + } + } +} + +pub struct VaapiH264EncoderNode { + config: VaapiH264EncoderConfig, +} + +impl VaapiH264EncoderNode { + #[allow(clippy::missing_errors_doc)] + pub fn new(config: VaapiH264EncoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "VaapiH264EncoderNode only supports hardware encoding; \ + use video::openh264::encoder for CPU encode" + .into(), + )); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for VaapiH264EncoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![ + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::I420, + }), + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + ], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::H264, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + fn content_type(&self) -> Option { + Some(H264_CONTENT_TYPE.to_string()) + } + + async fn run(self: Box, context: NodeContext) -> Result<(), StreamKitError> { + encoder_trait::run_encoder(*self, context).await + } +} + +impl EncoderNodeRunner for VaapiH264EncoderNode { + const CONTENT_TYPE: &'static str = H264_CONTENT_TYPE; + const NODE_LABEL: &'static str = "VaapiH264EncoderNode"; + const PACKETS_COUNTER_NAME: &'static str = "vaapi_h264_encoder_packets_processed"; + const DURATION_HISTOGRAM_NAME: &'static str = "vaapi_h264_encode_duration"; + + fn spawn_codec_task( + self, + encode_rx: mpsc::Receiver<(VideoFrame, Option)>, + result_tx: mpsc::Sender>, + duration_histogram: opentelemetry::metrics::Histogram, + ) -> tokio::task::JoinHandle<()> { + encoder_trait::spawn_standard_encode_task::( + self.config, + encode_rx, + result_tx, + duration_histogram, + ) + } +} + +// --------------------------------------------------------------------------- +// Encoder — internal codec wrapper +// --------------------------------------------------------------------------- + +/// Internal encoder state wrapping the custom VA-API H.264 encoder shim. +/// +/// Uses [`VaH264Encoder`] which drives `libva` directly, bypassing GBM +/// allocation entirely. Input frames are uploaded via the VA-API Image API. +/// +/// `!Send` due to internal `Rc` — lives entirely inside +/// a `spawn_blocking` thread. +struct VaapiH264Encoder { + encoder: VaH264Encoder, +} + +impl StandardVideoEncoder for VaapiH264Encoder { + type Config = VaapiH264EncoderConfig; + const CODEC_NAME: &'static str = "VA-API H.264"; + + fn new_encoder(width: u32, height: u32, config: &Self::Config) -> Result { + let (display, path) = open_va_display(config.render_device.as_ref())?; + tracing::info!(device = %path, width, height, "VA-API H.264 encoder opening"); + + let enc_config = H264EncConfig { + width, + height, + quality: config.quality, + framerate: config.framerate, + low_power: config.low_power, + }; + + let encoder = VaH264Encoder::new(display, &enc_config)?; + + tracing::info!( + device = %path, + width, + height, + coded_width = encoder.coded_width(), + coded_height = encoder.coded_height(), + quality = config.quality, + "VA-API H.264 encoder created" + ); + + Ok(Self { encoder }) + } + + fn encode( + &mut self, + frame: &VideoFrame, + metadata: Option, + ) -> Result, String> { + let force_keyframe = metadata.as_ref().and_then(|m| m.keyframe).unwrap_or(false); + + let bitstream = self.encoder.encode_frame(frame, force_keyframe)?; + + let out_meta = merge_h264_keyframe_metadata(metadata, &bitstream); + Ok(vec![EncodedPacket { data: Bytes::from(bitstream), metadata: out_meta }]) + } + + fn flush_encoder(&mut self) -> Result, String> { + self.encoder.flush()?; + Ok(Vec::new()) + } + + fn flush_on_dimension_change() -> bool { + true + } +} + +// --------------------------------------------------------------------------- +// Keyframe detection +// --------------------------------------------------------------------------- + +/// Detect whether an H.264 Annex B bitstream contains an IDR (keyframe) +/// NAL unit. +/// +/// Scans for Annex B start codes (`00 00 01` or `00 00 00 01`) and checks +/// whether any NAL unit has `nal_unit_type == 5` (IDR slice). This is the +/// standard way to identify keyframes in H.264 elementary streams. +fn h264_bitstream_is_idr(bitstream: &[u8]) -> bool { + let len = bitstream.len(); + let mut i = 0; + while i + 2 < len { + // Look for 3-byte start code 00 00 01. + if bitstream[i] == 0 && bitstream[i + 1] == 0 && bitstream[i + 2] == 1 { + let nal_pos = i + 3; + if nal_pos < len { + let nal_type = bitstream[nal_pos] & 0x1F; + if nal_type == 5 { + return true; // IDR slice + } + } + i = nal_pos; + // Also handle 4-byte start code 00 00 00 01. + } else if i + 3 < len + && bitstream[i] == 0 + && bitstream[i + 1] == 0 + && bitstream[i + 2] == 0 + && bitstream[i + 3] == 1 + { + let nal_pos = i + 4; + if nal_pos < len { + let nal_type = bitstream[nal_pos] & 0x1F; + if nal_type == 5 { + return true; // IDR slice + } + } + i = nal_pos; + } else { + i += 1; + } + } + false +} + +/// Build output metadata with the keyframe flag set from encoder output. +/// +/// Uses bitstream-level detection because cros-codecs' +/// `CodedBitstreamBuffer.metadata.force_keyframe` only reflects the +/// *caller's* request — not encoder-initiated periodic keyframes from +/// the `LowDelay` prediction structure. +fn merge_h264_keyframe_metadata( + metadata: Option, + bitstream: &[u8], +) -> Option { + let is_keyframe = h264_bitstream_is_idr(bitstream); + Some(match metadata { + Some(mut m) => { + m.keyframe = Some(is_keyframe); + m + }, + None => PacketMetadata { + timestamp_us: None, + duration_us: None, + sequence: None, + keyframe: Some(is_keyframe), + }, + }) +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +use schemars::schema_for; +use streamkit_core::registry::StaticPins; + +#[allow(clippy::expect_used, clippy::missing_panics_doc)] +pub fn register_vaapi_h264_nodes(registry: &mut NodeRegistry) { + let default_decoder = VaapiH264DecoderNode::new(VaapiH264DecoderConfig::default()) + .expect("default VA-API H.264 decoder config should be valid"); + registry.register_static_with_description( + "video::vaapi::h264_decoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(VaapiH264DecoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(VaapiH264DecoderConfig)) + .expect("VaapiH264DecoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_decoder.input_pins(), outputs: default_decoder.output_pins() }, + vec![ + "video".to_string(), + "codecs".to_string(), + "h264".to_string(), + "hw".to_string(), + "vaapi".to_string(), + ], + false, + "Decodes H.264-compressed packets into raw NV12 video frames using VA-API \ + hardware acceleration. Requires a VA-API capable GPU (Intel Sandy Bridge+, \ + AMD, or NVIDIA with nvidia-vaapi-driver).", + ); + + let default_encoder = VaapiH264EncoderNode::new(VaapiH264EncoderConfig::default()) + .expect("default VA-API H.264 encoder config should be valid"); + registry.register_static_with_description( + "video::vaapi::h264_encoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(VaapiH264EncoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(VaapiH264EncoderConfig)) + .expect("VaapiH264EncoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_encoder.input_pins(), outputs: default_encoder.output_pins() }, + vec![ + "video".to_string(), + "codecs".to_string(), + "h264".to_string(), + "hw".to_string(), + "vaapi".to_string(), + ], + false, + "Encodes raw NV12/I420 video frames into H.264-compressed packets using VA-API \ + hardware acceleration. Uses constant-quality (CQP) rate control. Requires a \ + VA-API capable GPU with H.264 encode support (Intel, AMD).", + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_macros)] +mod tests { + use super::*; + + // ── Unit tests (no GPU required) ───────────────────────────────── + + #[test] + fn test_force_cpu_rejected_decoder() { + let config = + VaapiH264DecoderConfig { hw_accel: HwAccelMode::ForceCpu, ..Default::default() }; + let result = VaapiH264DecoderNode::new(config); + assert!(result.is_err(), "ForceCpu should be rejected for VA-API H.264 decoder"); + } + + #[test] + fn test_force_cpu_rejected_encoder() { + let config = + VaapiH264EncoderConfig { hw_accel: HwAccelMode::ForceCpu, ..Default::default() }; + let result = VaapiH264EncoderNode::new(config); + assert!(result.is_err(), "ForceCpu should be rejected for VA-API H.264 encoder"); + } + + #[test] + fn test_default_configs() { + let dec = VaapiH264DecoderConfig::default(); + assert!(dec.render_device.is_none()); + assert!(matches!(dec.hw_accel, HwAccelMode::Auto)); + + let enc = VaapiH264EncoderConfig::default(); + assert!(enc.render_device.is_none()); + assert_eq!(enc.quality, DEFAULT_QUALITY); + assert_eq!(enc.framerate, DEFAULT_FRAMERATE); + assert!(!enc.low_power); + assert!(matches!(enc.hw_accel, HwAccelMode::Auto)); + } + + #[test] + fn test_decoder_pins() { + let node = VaapiH264DecoderNode::new(VaapiH264DecoderConfig::default()).unwrap(); + assert_eq!(node.input_pins().len(), 1); + assert_eq!(node.output_pins().len(), 1); + assert_eq!(node.input_pins()[0].name, "in"); + assert_eq!(node.output_pins()[0].name, "out"); + } + + #[test] + fn test_encoder_pins() { + let node = VaapiH264EncoderNode::new(VaapiH264EncoderConfig::default()).unwrap(); + assert_eq!(node.input_pins().len(), 1); + assert_eq!(node.output_pins().len(), 1); + assert_eq!(node.input_pins()[0].name, "in"); + assert_eq!(node.output_pins()[0].name, "out"); + // Encoder should accept both I420 and NV12 inputs. + assert_eq!(node.input_pins()[0].accepts_types.len(), 2); + } + + #[test] + fn test_encoder_content_type() { + let node = VaapiH264EncoderNode::new(VaapiH264EncoderConfig::default()).unwrap(); + assert_eq!(node.content_type(), Some(H264_CONTENT_TYPE.to_string())); + } + + // ── Registration test ──────────────────────────────────────────── + + #[test] + fn test_registration() { + let mut registry = NodeRegistry::new(); + register_vaapi_h264_nodes(&mut registry); + assert!( + registry.create_node("video::vaapi::h264_decoder", None).is_ok(), + "VA-API H.264 decoder should be registered" + ); + assert!( + registry.create_node("video::vaapi::h264_encoder", None).is_ok(), + "VA-API H.264 encoder should be registered" + ); + } + + // ── GPU integration tests ──────────────────────────────────────── + // + // These require a VA-API capable GPU with H.264 support. They are + // compiled with the `vaapi` feature but skip at runtime if no VA-API + // device is available. + + /// Check whether a usable VA-API display can be opened. + fn vaapi_available() -> bool { + use super::super::vaapi_av1::resolve_render_device; + let path = resolve_render_device(None); + libva::Display::open_drm_display(std::path::Path::new(&path)).is_ok() + } + + /// Check whether the VA-API driver supports H.264 *encoding*. + /// + /// NVIDIA's community `nvidia-vaapi-driver` only supports decode, so + /// encode tests must be skipped on NVIDIA GPUs to avoid false failures. + fn vaapi_h264_encode_available() -> bool { + if !vaapi_available() { + return false; + } + VaapiH264Encoder::new_encoder(64, 64, &VaapiH264EncoderConfig::default()).is_ok() + } + + /// Encoder + Decoder roundtrip: encode 5 NV12 frames, decode them back, + /// verify dimensions and pixel format. + #[tokio::test] + async fn test_vaapi_h264_encode_decode_roundtrip() { + if !vaapi_h264_encode_available() { + eprintln!("SKIP: no VA-API H.264 encode support available"); + return; + } + + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, + create_test_context, create_test_video_frame, + }; + use std::borrow::Cow; + use std::collections::HashMap; + + // --- Encode --- + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder_config = VaapiH264EncoderConfig { + render_device: None, + hw_accel: HwAccelMode::Auto, + quality: 40, // fast, lower quality for test speed + framerate: 30, + low_power: false, + }; + let encoder = VaapiH264EncoderNode::new(encoder_config).unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + for index in 0_u64..5 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(1_000 + 33_333 * index), + duration_us: Some(33_333), + sequence: Some(index), + keyframe: Some(true), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "VA-API H.264 encoder produced no packets"); + + // --- Decode --- + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = VaapiH264DecoderNode::new(VaapiH264DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + if let Packet::Binary { data, metadata, .. } = packet { + dec_input_tx + .send(Packet::Binary { + data, + content_type: Some(Cow::Borrowed(H264_CONTENT_TYPE)), + metadata, + }) + .await + .unwrap(); + } + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "VA-API H.264 decoder produced no frames"); + + for packet in decoded_packets { + match packet { + Packet::Video(frame) => { + assert_eq!(frame.width, 64); + assert_eq!(frame.height, 64); + assert_eq!(frame.pixel_format, PixelFormat::Nv12); + assert!(!frame.data().is_empty(), "Decoded frame should have data"); + }, + _ => panic!("Expected Video packet from VA-API H.264 decoder"), + } + } + } + + // ── Keyframe detection unit tests ──────────────────────────────── + + #[test] + fn test_h264_idr_detection_with_4byte_start_code() { + // Annex B bitstream with 4-byte start code + IDR NAL (type 5). + // NAL header byte: forbidden_zero_bit(1)=0, nal_ref_idc(2)=3, nal_type(5)=5 + // → 0b_0_11_00101 = 0x65 + let bitstream: Vec = vec![ + 0x00, 0x00, 0x00, 0x01, 0x65, // IDR slice NAL + 0xAA, 0xBB, // dummy slice data + ]; + assert!(h264_bitstream_is_idr(&bitstream)); + } + + #[test] + fn test_h264_idr_detection_with_3byte_start_code() { + // Annex B bitstream with 3-byte start code + IDR NAL (type 5). + let bitstream: Vec = vec![ + 0x00, 0x00, 0x01, 0x65, // IDR slice NAL + 0xAA, // dummy data + ]; + assert!(h264_bitstream_is_idr(&bitstream)); + } + + #[test] + fn test_h264_non_idr_detection() { + // Non-IDR slice NAL (type 1). + // NAL header: nal_ref_idc=2, nal_type=1 → 0b_0_10_00001 = 0x41 + let bitstream: Vec = vec![ + 0x00, 0x00, 0x00, 0x01, 0x41, // Non-IDR slice NAL + 0xCC, + ]; + assert!(!h264_bitstream_is_idr(&bitstream)); + } + + #[test] + fn test_h264_idr_with_sps_pps_prefix() { + // Typical encoder output: SPS (type 7) + PPS (type 8) + IDR (type 5). + let bitstream: Vec = vec![ + 0x00, 0x00, 0x00, 0x01, 0x67, // SPS NAL (type 7) + 0x42, 0x00, 0x1E, // dummy SPS data + 0x00, 0x00, 0x00, 0x01, 0x68, // PPS NAL (type 8) + 0xCE, 0x38, 0x80, // dummy PPS data + 0x00, 0x00, 0x00, 0x01, 0x65, // IDR slice NAL (type 5) + 0x88, 0x80, // dummy slice data + ]; + assert!(h264_bitstream_is_idr(&bitstream)); + } + + #[test] + fn test_h264_idr_detection_empty() { + assert!(!h264_bitstream_is_idr(&[])); + } + + #[test] + fn test_merge_h264_keyframe_metadata_with_existing() { + let meta = PacketMetadata { + timestamp_us: Some(100_000), + duration_us: Some(33_333), + sequence: Some(3), + keyframe: None, + }; + // IDR bitstream + let bitstream: Vec = vec![0x00, 0x00, 0x00, 0x01, 0x65, 0xAA]; + let result = merge_h264_keyframe_metadata(Some(meta), &bitstream).unwrap(); + assert_eq!(result.keyframe, Some(true)); + assert_eq!(result.timestamp_us, Some(100_000)); + assert_eq!(result.sequence, Some(3)); + } + + #[test] + fn test_merge_h264_keyframe_metadata_without_existing() { + // Non-IDR bitstream + let bitstream: Vec = vec![0x00, 0x00, 0x00, 0x01, 0x41, 0xBB]; + let result = merge_h264_keyframe_metadata(None, &bitstream).unwrap(); + assert_eq!(result.keyframe, Some(false)); + assert!(result.timestamp_us.is_none()); + } +} diff --git a/crates/nodes/src/video/vaapi_h264_enc.rs b/crates/nodes/src/video/vaapi_h264_enc.rs new file mode 100644 index 00000000..9753314f --- /dev/null +++ b/crates/nodes/src/video/vaapi_h264_enc.rs @@ -0,0 +1,1092 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Custom VA-API H.264 encoder shim. +//! +//! Drives `libva` directly for H.264 encoding — no dependency on the +//! `cros-codecs` encoder infrastructure. This lets the H.264 encoder use the +//! VA-API Image API path (upload NV12 via `vaCreateImage`/`vaPutImage`) instead +//! of GBM buffer allocation, which Mesa's iris driver rejects for NV12 on some +//! hardware (e.g. Intel Tiger Lake with Mesa ≤ 23.x). +//! +//! The encoder implements a simple IPP low-delay prediction structure: +//! periodic IDR keyframes with single-reference P frames in between. +//! +//! # Why not use cros-codecs? +//! +//! `cros_codecs::encoder::stateless::StatelessEncoder::new_vaapi()` requires +//! the input frame type to implement `VideoFrame`, which in turn requires +//! `Send + Sync`. `libva::Surface<()>` contains `Rc` and therefore +//! cannot satisfy those bounds. The only workaround was to call the +//! crate-private `new_h264()` constructor, requiring either a vendored copy or +//! a fork. This shim avoids that entirely by calling `libva` directly. + +use std::rc::Rc; + +use cros_codecs::libva::{ + self, BufferType, Context, Display, EncCodedBuffer, EncMiscParameter, + EncMiscParameterFrameRate, EncMiscParameterRateControl, EncPictureParameter, + EncPictureParameterBufferH264, EncSequenceParameter, EncSequenceParameterBufferH264, + EncSliceParameter, EncSliceParameterBufferH264, H264EncFrameCropOffsets, H264EncPicFields, + H264EncSeqFields, H264VuiFields, MappedCodedBuffer, Picture, PictureH264, RcFlags, Surface, + UsageHint, VAEntrypoint, VAProfile, VA_INVALID_ID, VA_PICTURE_H264_INVALID, + VA_PICTURE_H264_SHORT_TERM_REFERENCE, VA_RT_FORMAT_YUV420, +}; + +use super::vaapi_av1::write_nv12_to_va_surface; +use streamkit_core::types::{PixelFormat, VideoFrame}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// H.264 macroblock size in pixels. +const MB_SIZE: u32 = 16; + +/// Minimum QP value (H.264 spec). +const MIN_QP: u32 = 1; + +/// Maximum QP value (H.264 spec). +const MAX_QP: u32 = 51; + +/// Initial scratch surface pool size for reconstructed reference frames. +const SCRATCH_POOL_SIZE: usize = 4; + +/// Default coded buffer size when bitrate is not explicitly set (CQP mode). +const DEFAULT_CODED_BUF_SIZE: usize = 1_500_000; + +/// H.264 slice type constants (Table 7-6). +const SLICE_TYPE_P: u8 = 0; +const SLICE_TYPE_I: u8 = 2; + +// --------------------------------------------------------------------------- +// Encoder configuration +// --------------------------------------------------------------------------- + +/// Configuration for the custom VA-API H.264 encoder. +pub(super) struct H264EncConfig { + /// Display (visible) width. + pub width: u32, + /// Display (visible) height. + pub height: u32, + /// Constant quality parameter (0–51). + pub quality: u32, + /// Framerate in FPS (used for rate-control hints and VUI timing). + pub framerate: u32, + /// Use low-power encoding entrypoint if `true`. + pub low_power: bool, +} + +// --------------------------------------------------------------------------- +// Reference frame tracking +// --------------------------------------------------------------------------- + +/// Metadata for a reference frame in the DPB. +struct RefPic { + /// VA surface used as the reconstructed reference. + surface: Surface<()>, + /// Picture order count. + poc: u16, + /// `frame_num` in the H.264 bitstream. + frame_num: u32, +} + +// --------------------------------------------------------------------------- +// Encoder +// --------------------------------------------------------------------------- + +/// A self-contained VA-API H.264 encoder. +/// +/// Manages its own VA config, context, scratch surface pool, and reference +/// frame. Call [`encode_frame`] for each input frame and collect the returned +/// bitstream bytes. +pub(super) struct VaH264Encoder { + display: Rc, + context: Rc, + + /// Display (visible) resolution. + width: u32, + height: u32, + + /// Macroblock-aligned coded resolution. + coded_width: u32, + coded_height: u32, + + /// Pool of scratch surfaces for reconstructed reference frames. + scratch_surfaces: Vec>, + + /// Current reference frame (most recent reconstructed P or I frame). + reference: Option, + + /// Monotonically increasing frame counter. + frame_count: u64, + + /// IDR period — number of frames between IDR keyframes. + idr_period: u32, + + /// Constant quality parameter (QP). + qp: u32, + + /// Framerate for rate-control / VUI. + framerate: u32, + + /// Number of macroblocks per frame. + num_mbs: u32, + + /// Width in macroblocks. + width_in_mbs: u16, + + /// Height in macroblocks. + height_in_mbs: u16, + + /// Frame cropping offsets (for non-MB-aligned resolutions). + frame_crop: Option, + + /// `log2_max_frame_num_minus4` derived from `idr_period`. + log2_max_frame_num_minus4: u32, + + /// `log2_max_pic_order_cnt_lsb_minus4` derived from `idr_period`. + log2_max_pic_order_cnt_lsb_minus4: u32, +} + +impl VaH264Encoder { + /// Create a new encoder. + /// + /// Opens the VA display, creates config + context, and pre-allocates + /// scratch surfaces for reference frame reconstruction. + pub fn new(display: Rc, config: &H264EncConfig) -> Result { + let coded_width = align_up(config.width, MB_SIZE); + let coded_height = align_up(config.height, MB_SIZE); + + let low_power = resolve_low_power(&display, config.low_power)?; + + let entrypoint = if low_power { + VAEntrypoint::VAEntrypointEncSliceLP + } else { + VAEntrypoint::VAEntrypointEncSlice + }; + + let va_config = display + .create_config( + vec![ + libva::VAConfigAttrib { + type_: libva::VAConfigAttribType::VAConfigAttribRTFormat, + value: VA_RT_FORMAT_YUV420, + }, + libva::VAConfigAttrib { + type_: libva::VAConfigAttribType::VAConfigAttribRateControl, + value: libva::VA_RC_CQP, + }, + ], + VAProfile::VAProfileH264Main, + entrypoint, + ) + .map_err(|e| format!("failed to create VA config: {e}"))?; + + let context = display + .create_context::<()>(&va_config, coded_width, coded_height, None, true) + .map_err(|e| format!("failed to create VA context: {e}"))?; + + // Pre-allocate scratch surfaces for reference frame reconstruction. + let scratch_surfaces = display + .create_surfaces( + VA_RT_FORMAT_YUV420, + None, + coded_width, + coded_height, + Some(UsageHint::USAGE_HINT_ENCODER), + vec![(); SCRATCH_POOL_SIZE], + ) + .map_err(|e| format!("failed to create scratch surfaces: {e}"))?; + + let width_in_mbs = (coded_width / MB_SIZE) as u16; + let height_in_mbs = (coded_height / MB_SIZE) as u16; + let num_mbs = u32::from(width_in_mbs) * u32::from(height_in_mbs); + + // Compute frame cropping if the display resolution is not MB-aligned. + let frame_crop = if coded_width != config.width || coded_height != config.height { + // H.264 spec: crop offsets are in units of 2 pixels for 4:2:0. + let right = (coded_width - config.width) / 2; + let bottom = (coded_height - config.height) / 2; + Some(H264EncFrameCropOffsets::new(0, right, 0, bottom)) + } else { + None + }; + + // IDR period: one keyframe every 1024 frames (~34 s at 30 fps). + // Not yet exposed in H264EncConfig; a reasonable default for + // low-latency streaming. Callers can still force an IDR at any + // time via the `force_keyframe` parameter on `encode_frame`. + let idr_period: u32 = 1024; + let qp = config.quality.clamp(MIN_QP, MAX_QP); + + // Compute log2 values for max_frame_num and max_pic_order_cnt_lsb. + let log2_max_frame_num_minus4 = log2_ceil(idr_period).saturating_sub(4); + let log2_max_pic_order_cnt_lsb_minus4 = log2_ceil(idr_period * 2).saturating_sub(4); + + Ok(Self { + display, + context, + width: config.width, + height: config.height, + coded_width, + coded_height, + scratch_surfaces, + reference: None, + frame_count: 0, + idr_period, + qp, + framerate: config.framerate, + num_mbs, + width_in_mbs, + height_in_mbs, + frame_crop, + log2_max_frame_num_minus4, + log2_max_pic_order_cnt_lsb_minus4, + }) + } + + /// Encode a single frame, returning the H.264 bitstream bytes. + /// + /// The caller is responsible for providing NV12 or I420 pixel data in the + /// `VideoFrame`. The frame is uploaded to a VA surface via the Image API + /// before encoding. + pub fn encode_frame( + &mut self, + frame: &VideoFrame, + force_keyframe: bool, + ) -> Result, String> { + if frame.pixel_format == PixelFormat::Rgba8 { + return Err("VA-API H.264 encoder requires NV12 or I420 input; \ + insert a video::pixel_convert node upstream" + .into()); + } + + // Determine whether this frame is an IDR. + let frame_in_gop = (self.frame_count % u64::from(self.idr_period)) as u32; + let is_idr = self.frame_count == 0 || frame_in_gop == 0 || force_keyframe; + let is_i_frame = is_idr; + + // Reset reference on IDR. + if is_idr { + self.reference = None; + } + + let frame_num = if is_idr { 0 } else { frame_in_gop }; + let poc = ((frame_num * 2) & 0xFFFF) as u16; + + // Create input surface and upload pixel data via Image API. + let nv12_fourcc: u32 = super::vaapi_av1::nv12_fourcc().into(); + let mut input_surfaces = self + .display + .create_surfaces( + VA_RT_FORMAT_YUV420, + Some(nv12_fourcc), + self.coded_width, + self.coded_height, + Some(UsageHint::USAGE_HINT_ENCODER), + vec![()], + ) + .map_err(|e| format!("failed to create input surface: {e}"))?; + let input_surface = + input_surfaces.pop().ok_or_else(|| "create_surfaces returned empty vec".to_string())?; + + write_nv12_to_va_surface(&self.display, &input_surface, frame)?; + + // Get a scratch surface for the reconstructed frame. + let recon_surface = self.get_scratch_surface()?; + + // Create coded buffer. + let coded_buf = self + .context + .create_enc_coded(DEFAULT_CODED_BUF_SIZE) + .map_err(|e| format!("failed to create coded buffer: {e}"))?; + + // Build VA parameter buffers. + let seq_param = self.build_seq_param(); + let pic_param = + self.build_pic_param(is_idr, &recon_surface, &coded_buf, frame_num as u16, poc); + let slice_param = self.build_slice_param(is_i_frame, is_idr, poc, frame_num); + let rc_param = self.build_rc_param(); + let framerate_param = BufferType::EncMiscParameter(EncMiscParameter::FrameRate( + EncMiscParameterFrameRate::new(self.framerate, 0), + )); + + // Create picture, attach buffers, and submit. + let mut picture = Picture::new(self.frame_count, Rc::clone(&self.context), input_surface); + + picture.add_buffer( + self.context + .create_buffer(seq_param) + .map_err(|e| format!("failed to create seq param buffer: {e}"))?, + ); + picture.add_buffer( + self.context + .create_buffer(pic_param) + .map_err(|e| format!("failed to create pic param buffer: {e}"))?, + ); + picture.add_buffer( + self.context + .create_buffer(slice_param) + .map_err(|e| format!("failed to create slice param buffer: {e}"))?, + ); + picture.add_buffer( + self.context + .create_buffer(rc_param) + .map_err(|e| format!("failed to create rc param buffer: {e}"))?, + ); + picture.add_buffer( + self.context + .create_buffer(framerate_param) + .map_err(|e| format!("failed to create framerate param buffer: {e}"))?, + ); + + let picture = picture.begin().map_err(|e| format!("vaBeginPicture failed: {e}"))?; + let picture = picture.render().map_err(|e| format!("vaRenderPicture failed: {e}"))?; + let picture = picture.end().map_err(|e| format!("vaEndPicture failed: {e}"))?; + + // Sync and read coded output. + let _synced = picture.sync().map_err(|(e, _)| format!("vaSyncSurface failed: {e}"))?; + + let mapped = MappedCodedBuffer::new(&coded_buf) + .map_err(|e| format!("failed to map coded buffer: {e}"))?; + + let mut coded_data = Vec::new(); + for segment in mapped.segments() { + coded_data.extend_from_slice(segment.buf); + } + + // For IDR frames, ensure SPS/PPS NALUs are present in the bitstream. + // Some VA-API drivers (notably Intel iHD) do not auto-generate SPS/PPS + // in the coded output — the `cros-libva` crate does not expose packed + // header buffer types, so we cannot request them via the VA-API. + // Instead we generate the NALUs ourselves and prepend them. + let bitstream = if is_idr { + let has_sps_pps = bitstream_contains_sps_pps(&coded_data); + if has_sps_pps { + tracing::debug!("IDR frame already contains SPS/PPS from driver"); + coded_data + } else { + tracing::debug!("IDR frame missing SPS/PPS — prepending generated NALUs"); + let mut out = Vec::with_capacity(coded_data.len() + 128); + out.extend_from_slice(&self.build_sps_nalu()); + out.extend_from_slice(&self.build_pps_nalu()); + out.extend_from_slice(&coded_data); + out + } + } else { + coded_data + }; + + // Update reference frame, returning the old surface to the pool. + if let Some(old_ref) = self.reference.take() { + self.scratch_surfaces.push(old_ref.surface); + } + self.reference = Some(RefPic { surface: recon_surface, poc, frame_num }); + + self.frame_count += 1; + + Ok(bitstream) + } + + /// Flush the encoder — no-op for this synchronous implementation. + /// + /// Each `encode_frame` call produces output immediately, so there is + /// nothing to drain. + pub fn flush(&mut self) -> Result<(), String> { + Ok(()) + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// Get a scratch surface for the reconstructed reference frame. + /// + /// Rotates through the pre-allocated pool. + fn get_scratch_surface(&mut self) -> Result, String> { + if self.scratch_surfaces.is_empty() { + // Replenish the pool. + let new_surfaces = self + .display + .create_surfaces( + VA_RT_FORMAT_YUV420, + None, + self.coded_width, + self.coded_height, + Some(UsageHint::USAGE_HINT_ENCODER), + vec![(); SCRATCH_POOL_SIZE], + ) + .map_err(|e| format!("failed to replenish scratch surfaces: {e}"))?; + self.scratch_surfaces = new_surfaces; + } + self.scratch_surfaces.pop().ok_or_else(|| "scratch surface pool exhausted".to_string()) + } + + /// Build the sequence parameter buffer (SPS-derived fields). + fn build_seq_param(&self) -> BufferType { + let seq_fields = H264EncSeqFields::new( + 1, // chroma_format_idc = 1 (4:2:0) + 1, // frame_mbs_only_flag + 0, // mb_adaptive_frame_field_flag + 0, // seq_scaling_matrix_present_flag + 1, // direct_8x8_inference_flag (required for Level >= 3.0) + self.log2_max_frame_num_minus4, + 0, // pic_order_cnt_type = 0 + self.log2_max_pic_order_cnt_lsb_minus4, + 0, // delta_pic_order_always_zero_flag + ); + + let vui_fields = H264VuiFields::new( + 1, // aspect_ratio_info_present_flag + 1, // timing_info_present_flag + 0, // bitstream_restriction_flag + 0, // log2_max_mv_length_horizontal + 0, // log2_max_mv_length_vertical + 0, // fixed_frame_rate_flag + 0, // low_delay_hrd_flag + 0, // motion_vectors_over_pic_boundaries_flag + ); + + BufferType::EncSequenceParameter(EncSequenceParameter::H264( + EncSequenceParameterBufferH264::new( + 0, // seq_parameter_set_id + 41, // level_idc (Level 4.1) + self.idr_period, // intra_period + self.idr_period, // intra_idr_period + 0, // ip_period (no B frames) + 0, // bits_per_second (CQP mode) + 1, // max_num_ref_frames + self.width_in_mbs, // picture_width_in_mbs + self.height_in_mbs, // picture_height_in_mbs + &seq_fields, + 0, // bit_depth_luma_minus8 + 0, // bit_depth_chroma_minus8 + 0, // num_ref_frames_in_pic_order_cnt_cycle + 0, // offset_for_non_ref_pic + 0, // offset_for_top_to_bottom_field + [0i32; 256], // offset_for_ref_frame + self.frame_crop + .as_ref() + .map(|c| H264EncFrameCropOffsets::new(c.left, c.right, c.top, c.bottom)), // frame_crop + Some(vui_fields), // vui_fields + 1, // aspect_ratio_idc (1:1 SAR) + 1, // sar_width + 1, // sar_height + 1, // num_units_in_tick + self.framerate * 2, // time_scale (2× framerate for field timing) + ), + )) + } + + /// Build the picture parameter buffer. + fn build_pic_param( + &self, + is_idr: bool, + recon_surface: &Surface<()>, + coded_buf: &EncCodedBuffer, + frame_num: u16, + poc: u16, + ) -> BufferType { + let is_reference = true; // All frames are used as references. + + // Current picture. + let curr_pic = PictureH264::new( + recon_surface.id(), + u32::from(frame_num), + VA_PICTURE_H264_SHORT_TERM_REFERENCE, + i32::from(poc), + i32::from(poc), + ); + + // Reference frames array (up to 16 slots). + let mut reference_frames: [PictureH264; 16] = std::array::from_fn(|_| build_invalid_pic()); + + if let Some(ref ref_pic) = self.reference { + reference_frames[0] = PictureH264::new( + ref_pic.surface.id(), + ref_pic.frame_num, + VA_PICTURE_H264_SHORT_TERM_REFERENCE, + i32::from(ref_pic.poc), + i32::from(ref_pic.poc), + ); + } + + let pic_fields = H264EncPicFields::new( + u32::from(is_idr), // idr_pic_flag + u32::from(is_reference), // reference_pic_flag + 0, // entropy_coding_mode_flag (CAVLC) + 0, // weighted_pred_flag + 0, // weighted_bipred_idc + 0, // constrained_intra_pred_flag + 0, // transform_8x8_mode_flag + 1, // deblocking_filter_control_present_flag + 0, // redundant_pic_cnt_present_flag + 0, // pic_order_present_flag + 0, // pic_scaling_matrix_present_flag + ); + + BufferType::EncPictureParameter(EncPictureParameter::H264( + EncPictureParameterBufferH264::new( + curr_pic, + reference_frames, + coded_buf.id(), + 0, // pic_parameter_set_id + 0, // seq_parameter_set_id + 0, // last_picture (not EOS) + frame_num, // frame_num + self.qp as u8, // pic_init_qp + 0, // num_ref_idx_l0_active_minus1 + 0, // num_ref_idx_l1_active_minus1 + 0, // chroma_qp_index_offset + 0, // second_chroma_qp_index_offset + &pic_fields, + ), + )) + } + + /// Build the slice parameter buffer. + fn build_slice_param( + &self, + is_i_frame: bool, + is_idr: bool, + poc: u16, + frame_num: u32, + ) -> BufferType { + let slice_type = if is_i_frame { SLICE_TYPE_I } else { SLICE_TYPE_P }; + + // Reference picture lists. + let mut ref_pic_list_0: [PictureH264; 32] = std::array::from_fn(|_| build_invalid_pic()); + let mut num_ref_idx_l0_active_minus1: u8 = 0; + let mut num_ref_idx_active_override_flag: u8 = 0; + + if !is_i_frame { + if let Some(ref ref_pic) = self.reference { + ref_pic_list_0[0] = PictureH264::new( + ref_pic.surface.id(), + ref_pic.frame_num, + VA_PICTURE_H264_SHORT_TERM_REFERENCE, + i32::from(ref_pic.poc), + i32::from(ref_pic.poc), + ); + num_ref_idx_l0_active_minus1 = 0; // 1 reference frame + num_ref_idx_active_override_flag = 1; + } + } + + let ref_pic_list_1: [PictureH264; 32] = std::array::from_fn(|_| build_invalid_pic()); + + let idr_pic_id = + if is_idr { (self.frame_count / u64::from(self.idr_period)) as u16 } else { 0 }; + + // Compute slice_qp_delta so that pic_init_qp + slice_qp_delta = target QP. + // Since we set pic_init_qp = self.qp, slice_qp_delta = 0. + let slice_qp_delta: i8 = 0; + + BufferType::EncSliceParameter(EncSliceParameter::H264(EncSliceParameterBufferH264::new( + 0, // macroblock_address (start of slice) + self.num_mbs, // num_macroblocks + VA_INVALID_ID, // macroblock_info (unused) + slice_type, + 0, // pic_parameter_set_id + idr_pic_id, + poc, // pic_order_cnt_lsb + 0, // delta_pic_order_cnt_bottom + [0i32; 2], // delta_pic_order_cnt + 0, // direct_spatial_mv_pred_flag + num_ref_idx_active_override_flag, + num_ref_idx_l0_active_minus1, + 0, // num_ref_idx_l1_active_minus1 + ref_pic_list_0, + ref_pic_list_1, + 0, // luma_log2_weight_denom + 0, // chroma_log2_weight_denom + 0, // luma_weight_l0_flag + [0i16; 32], // luma_weight_l0 + [0i16; 32], // luma_offset_l0 + 0, // chroma_weight_l0_flag + [[0i16; 2]; 32], // chroma_weight_l0 + [[0i16; 2]; 32], // chroma_offset_l0 + 0, // luma_weight_l1_flag + [0i16; 32], // luma_weight_l1 + [0i16; 32], // luma_offset_l1 + 0, // chroma_weight_l1_flag + [[0i16; 2]; 32], // chroma_weight_l1 + [[0i16; 2]; 32], // chroma_offset_l1 + 0, // cabac_init_idc (CAVLC) + slice_qp_delta, + 0, // disable_deblocking_filter_idc (enabled) + 0, // slice_alpha_c0_offset_div2 + 0, // slice_beta_offset_div2 + ))) + } + + /// Build the rate-control miscellaneous parameter buffer (CQP mode). + fn build_rc_param(&self) -> BufferType { + let rc_flags = RcFlags::new( + 0, // reset + 1, // disable_frame_skip + 0, // disable_bit_stuffing + 0, // mb_rate_control + 0, // temporal_id + 0, // cfs_i_frames + 0, // enable_parallel_brc + 0, // enable_dynamic_scaling + 0, // frame_tolerance_mode + ); + + BufferType::EncMiscParameter(EncMiscParameter::RateControl( + EncMiscParameterRateControl::new( + 0, // bits_per_second (CQP → 0) + 100, // target_percentage + 1500, // window_size (ms) + self.qp, // initial_qp + MIN_QP, // min_qp + 0, // basic_unit_size + rc_flags, 0, // icq_quality_factor + MAX_QP, // max_qp + 0, // quality_factor + 0, // target_frame_size + ), + )) + } + + /// Accessors for integration with the node layer. + pub fn coded_width(&self) -> u32 { + self.coded_width + } + pub fn coded_height(&self) -> u32 { + self.coded_height + } +} + +// --------------------------------------------------------------------------- +// H.264 NALU generation (SPS / PPS) +// --------------------------------------------------------------------------- + +/// Minimal bitstream writer for constructing H.264 NALUs with exp-Golomb +/// coded fields. +struct BitWriter { + buf: Vec, + byte: u8, + bits: u8, +} + +impl BitWriter { + fn new() -> Self { + Self { buf: Vec::with_capacity(64), byte: 0, bits: 0 } + } + + /// Write `n` bits from the low end of `val`. + fn write_bits(&mut self, val: u32, n: u8) { + for i in (0..n).rev() { + self.byte = (self.byte << 1) | (((val >> i) & 1) as u8); + self.bits += 1; + if self.bits == 8 { + self.buf.push(self.byte); + self.byte = 0; + self.bits = 0; + } + } + } + + /// Write a single bit. + fn write_bit(&mut self, val: bool) { + self.write_bits(u32::from(val), 1); + } + + /// Write an unsigned exp-Golomb code (ue(v)). + fn write_ue(&mut self, val: u32) { + let x = val + 1; + let len = 32 - x.leading_zeros(); // number of bits in x + // Leading zeros: len - 1 + for _ in 0..len - 1 { + self.write_bit(false); + } + self.write_bits(x, len as u8); + } + + /// Write a signed exp-Golomb code (se(v)). + fn write_se(&mut self, val: i32) { + let mapped = if val > 0 { (val as u32) * 2 - 1 } else { ((-val) as u32) * 2 }; + self.write_ue(mapped); + } + + /// Finish the NALU: add RBSP stop bit + trailing zero bits. + fn finish(mut self) -> Vec { + // RBSP stop bit. + self.write_bit(true); + // Pad remaining bits to byte boundary. + if self.bits > 0 { + self.byte <<= 8 - self.bits; + self.buf.push(self.byte); + } + self.buf + } +} + +impl VaH264Encoder { + /// Generate a complete SPS NALU (Annex B start code + RBSP). + fn build_sps_nalu(&self) -> Vec { + let mut w = BitWriter::new(); + + // NAL header: forbidden_zero_bit(1), nal_ref_idc(2)=3, nal_unit_type(5)=7 (SPS) + w.write_bits(0x67, 8); + + // profile_idc = 77 (Main) + w.write_bits(77, 8); + + // constraint_set0..3_flags, reserved_zero_4bits + // constraint_set0_flag=0, constraint_set1_flag=1 (Main), set2=0, set3=0, reserved=0000 + w.write_bits(0b0100_0000, 8); + + // level_idc = 41 + w.write_bits(41, 8); + + // seq_parameter_set_id + w.write_ue(0); + + // log2_max_frame_num_minus4 + w.write_ue(self.log2_max_frame_num_minus4); + + // pic_order_cnt_type = 0 + w.write_ue(0); + + // log2_max_pic_order_cnt_lsb_minus4 + w.write_ue(self.log2_max_pic_order_cnt_lsb_minus4); + + // max_num_ref_frames + w.write_ue(1); + + // gaps_in_frame_num_value_allowed_flag + w.write_bit(false); + + // pic_width_in_mbs_minus1 + w.write_ue(u32::from(self.width_in_mbs) - 1); + + // pic_height_in_map_units_minus1 + w.write_ue(u32::from(self.height_in_mbs) - 1); + + // frame_mbs_only_flag = 1 + w.write_bit(true); + + // (mb_adaptive_frame_field_flag omitted when frame_mbs_only_flag=1) + + // direct_8x8_inference_flag = 1 + w.write_bit(true); + + // frame_cropping_flag + offsets + if let Some(ref crop) = self.frame_crop { + w.write_bit(true); + w.write_ue(crop.left); + w.write_ue(crop.right); + w.write_ue(crop.top); + w.write_ue(crop.bottom); + } else { + w.write_bit(false); + } + + // vui_parameters_present_flag = 1 + w.write_bit(true); + + // --- VUI --- + // aspect_ratio_info_present_flag = 1 + w.write_bit(true); + // aspect_ratio_idc = 1 (1:1 SAR) + w.write_bits(1, 8); + + // overscan_info_present_flag = 0 + w.write_bit(false); + + // video_signal_type_present_flag = 0 + w.write_bit(false); + + // chroma_loc_info_present_flag = 0 + w.write_bit(false); + + // timing_info_present_flag = 1 + w.write_bit(true); + // num_units_in_tick = 1 + w.write_bits(1, 32); + // time_scale = framerate * 2 + w.write_bits(self.framerate * 2, 32); + // fixed_frame_rate_flag = 0 + w.write_bit(false); + + // nal_hrd_parameters_present_flag = 0 + w.write_bit(false); + // vcl_hrd_parameters_present_flag = 0 + w.write_bit(false); + + // pic_struct_present_flag = 0 + w.write_bit(false); + + // bitstream_restriction_flag = 0 + w.write_bit(false); + + // Build the NALU with Annex B start code. + let rbsp = w.finish(); + let mut nalu = Vec::with_capacity(rbsp.len() + 4); + nalu.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + nalu.extend_from_slice(&rbsp); + nalu + } + + /// Generate a complete PPS NALU (Annex B start code + RBSP). + fn build_pps_nalu(&self) -> Vec { + let mut w = BitWriter::new(); + + // NAL header: forbidden_zero_bit(1), nal_ref_idc(2)=3, nal_unit_type(5)=8 (PPS) + w.write_bits(0x68, 8); + + // pic_parameter_set_id + w.write_ue(0); + + // seq_parameter_set_id + w.write_ue(0); + + // entropy_coding_mode_flag = 0 (CAVLC) + w.write_bit(false); + + // bottom_field_pic_order_in_frame_present_flag = 0 + w.write_bit(false); + + // num_slice_groups_minus1 = 0 + w.write_ue(0); + + // num_ref_idx_l0_default_active_minus1 = 0 + w.write_ue(0); + + // num_ref_idx_l1_default_active_minus1 = 0 + w.write_ue(0); + + // weighted_pred_flag = 0 + w.write_bit(false); + + // weighted_bipred_idc = 0 + w.write_bits(0, 2); + + // pic_init_qp_minus26 + w.write_se(self.qp as i32 - 26); + + // pic_init_qs_minus26 + w.write_se(0); + + // chroma_qp_index_offset + w.write_se(0); + + // deblocking_filter_control_present_flag = 1 + w.write_bit(true); + + // constrained_intra_pred_flag = 0 + w.write_bit(false); + + // redundant_pic_cnt_present_flag = 0 + w.write_bit(false); + + // Build the NALU with Annex B start code. + let rbsp = w.finish(); + let mut nalu = Vec::with_capacity(rbsp.len() + 4); + nalu.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + nalu.extend_from_slice(&rbsp); + nalu + } +} + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + +/// Build an invalid `PictureH264` placeholder (fills unused reference slots). +fn build_invalid_pic() -> PictureH264 { + PictureH264::new(VA_INVALID_ID, 0, VA_PICTURE_H264_INVALID, 0, 0) +} + +/// Check whether an Annex B bitstream contains both SPS and PPS NALUs. +fn bitstream_contains_sps_pps(data: &[u8]) -> bool { + let mut has_sps = false; + let mut has_pps = false; + let len = data.len(); + let mut i = 0; + while i + 2 < len { + let sc_len = if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { + 3 + } else if i + 3 < len + && data[i] == 0 + && data[i + 1] == 0 + && data[i + 2] == 0 + && data[i + 3] == 1 + { + 4 + } else { + 0 + }; + if sc_len > 0 { + let nal_pos = i + sc_len; + if nal_pos < len { + let nal_type = data[nal_pos] & 0x1F; + if nal_type == 7 { + has_sps = true; + } + if nal_type == 8 { + has_pps = true; + } + } + i = nal_pos; + } else { + i += 1; + } + } + has_sps && has_pps +} + +/// Round `value` up to the next multiple of `alignment`. +fn align_up(value: u32, alignment: u32) -> u32 { + (value + alignment - 1) / alignment * alignment +} + +/// Compute ceil(log2(n)), minimum 0. +fn log2_ceil(n: u32) -> u32 { + if n <= 1 { + return 0; + } + 32 - (n - 1).leading_zeros() +} + +/// Resolve whether to use the low-power entrypoint. +/// +/// Auto-detects when the driver only supports `VAEntrypointEncSliceLP`. +fn resolve_low_power(display: &Display, requested: bool) -> Result { + let entrypoints = display + .query_config_entrypoints(VAProfile::VAProfileH264Main) + .map_err(|e| format!("failed to query H.264 entrypoints: {e}"))?; + + let has_lp = entrypoints.contains(&VAEntrypoint::VAEntrypointEncSliceLP); + let has_full = entrypoints.contains(&VAEntrypoint::VAEntrypointEncSlice); + + if !has_lp && !has_full { + return Err("VA-API driver does not support H.264 encoding (no EncSlice entrypoint)".into()); + } + + if requested { + if !has_lp { + return Err( + "low_power=true requested but VAEntrypointEncSliceLP is not supported".into() + ); + } + Ok(true) + } else if has_lp && !has_full { + tracing::info!("auto-selecting low-power H.264 encoder (VAEntrypointEncSliceLP)"); + Ok(true) + } else { + Ok(false) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_align_up() { + assert_eq!(align_up(1, 16), 16); + assert_eq!(align_up(16, 16), 16); + assert_eq!(align_up(17, 16), 32); + assert_eq!(align_up(1280, 16), 1280); + assert_eq!(align_up(720, 16), 720); + assert_eq!(align_up(1080, 16), 1088); + } + + #[test] + fn test_log2_ceil() { + assert_eq!(log2_ceil(0), 0); + assert_eq!(log2_ceil(1), 0); + assert_eq!(log2_ceil(2), 1); + assert_eq!(log2_ceil(3), 2); + assert_eq!(log2_ceil(4), 2); + assert_eq!(log2_ceil(5), 3); + assert_eq!(log2_ceil(1024), 10); + assert_eq!(log2_ceil(2048), 11); + } + + #[test] + fn test_bitwriter_bits() { + let mut w = BitWriter::new(); + w.write_bits(0b1010, 4); + w.write_bits(0b1100, 4); + let out = w.finish(); + // 1010 1100 + stop bit 1 + 0000000 padding + assert_eq!(out[0], 0b1010_1100); + assert_eq!(out[1], 0b1000_0000); + } + + #[test] + fn test_bitwriter_ue() { + // ue(0) = 1 (1 bit) + let mut w = BitWriter::new(); + w.write_ue(0); + let out = w.finish(); + assert_eq!(out[0], 0b1_1000000); // 1 + stop + pad + + // ue(1) = 010 (3 bits) + let mut w = BitWriter::new(); + w.write_ue(1); + let out = w.finish(); + assert_eq!(out[0], 0b010_1_0000); // 010 + stop + pad + + // ue(5) = 00110 (5 bits) + let mut w = BitWriter::new(); + w.write_ue(5); + let out = w.finish(); + assert_eq!(out[0], 0b00110_1_00); // 00110 + stop + pad + } + + #[test] + fn test_bitwriter_se() { + // se(0) → ue(0) = 1 + let mut w = BitWriter::new(); + w.write_se(0); + let out = w.finish(); + assert_eq!(out[0], 0b1_1000000); + + // se(1) → ue(1) = 010 + let mut w = BitWriter::new(); + w.write_se(1); + let out = w.finish(); + assert_eq!(out[0], 0b010_1_0000); + + // se(-1) → ue(2) = 011 + let mut w = BitWriter::new(); + w.write_se(-1); + let out = w.finish(); + assert_eq!(out[0], 0b011_1_0000); + } + + #[test] + fn test_bitstream_contains_sps_pps() { + // Bitstream with SPS (type 7) and PPS (type 8). + let data = [ + 0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1f, // SPS + 0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x38, 0x80, // PPS + 0x00, 0x00, 0x00, 0x01, 0x65, 0x11, 0x22, 0x33, // IDR slice + ]; + assert!(bitstream_contains_sps_pps(&data)); + + // Bitstream without SPS/PPS (only IDR slice). + let data_no_ps = [0x00, 0x00, 0x00, 0x01, 0x65, 0x11, 0x22, 0x33]; + assert!(!bitstream_contains_sps_pps(&data_no_ps)); + + // 3-byte start codes. + let data_3byte = [ + 0x00, 0x00, 0x01, 0x67, 0x42, // SPS + 0x00, 0x00, 0x01, 0x68, 0xce, // PPS + ]; + assert!(bitstream_contains_sps_pps(&data_3byte)); + + // Only SPS, no PPS. + let data_sps_only = [0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0]; + assert!(!bitstream_contains_sps_pps(&data_sps_only)); + } +} diff --git a/crates/nodes/src/video/vulkan_video.rs b/crates/nodes/src/video/vulkan_video.rs new file mode 100644 index 00000000..ed8bb860 --- /dev/null +++ b/crates/nodes/src/video/vulkan_video.rs @@ -0,0 +1,1453 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Vulkan Video HW-accelerated H.264 encoder and decoder nodes. +//! +//! Uses the [`vk-video`](https://crates.io/crates/vk-video) crate which wraps +//! the Vulkan Video extensions and integrates natively with `wgpu`. Decoded +//! frames are `wgpu::Texture`s — enabling a zero-copy path with the GPU +//! compositor in the future. +//! +//! This module provides: +//! - `VulkanVideoH264DecoderNode` — decodes H.264 packets to NV12 `VideoFrame`s +//! - `VulkanVideoH264EncoderNode` — encodes NV12 `VideoFrame`s to H.264 packets +//! +//! Both nodes perform runtime capability detection: if no Vulkan Video capable +//! GPU is found, node creation returns an error so the pipeline can fall back +//! to a CPU codec. +//! +//! # Feature gate +//! +//! Requires `vulkan_video` feature. + +use std::borrow::Cow; +use std::num::NonZeroU32; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use bytes::Bytes; +use opentelemetry::global; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use streamkit_core::stats::NodeStatsTracker; +use streamkit_core::types::{ + EncodedVideoFormat, Packet, PacketMetadata, PacketType, PixelFormat, RawVideoFormat, + VideoCodec, VideoFrame, VideoLayout, +}; +use streamkit_core::{ + config_helpers, get_codec_channel_capacity, packet_helpers, state_helpers, InputPin, + NodeContext, NodeRegistry, OutputPin, PinCardinality, PooledVideoData, ProcessorNode, + StreamKitError, VideoFramePool, +}; +use tokio::sync::mpsc; + +use super::HwAccelMode; +use super::H264_CONTENT_TYPE; + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +/// Configuration for the Vulkan Video H.264 decoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct VulkanVideoH264DecoderConfig { + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, +} + +impl Default for VulkanVideoH264DecoderConfig { + fn default() -> Self { + Self { hw_accel: HwAccelMode::Auto } + } +} + +/// Vulkan Video H.264 decoder node. +/// +/// Accepts H.264 encoded `Binary` packets on its `"in"` pin and emits +/// decoded NV12 `VideoFrame`s on its `"out"` pin. +/// +/// Internally uses `vk-video::BytesDecoder` for GPU-accelerated decoding, +/// which returns raw NV12 pixel data directly — avoiding explicit GPU +/// texture readback while still leveraging the Vulkan Video decode engine. +pub struct VulkanVideoH264DecoderNode { + config: VulkanVideoH264DecoderConfig, +} + +impl VulkanVideoH264DecoderNode { + /// Create a new decoder node with the given configuration. + /// + /// # Errors + /// + /// Returns an error if `hw_accel` is `ForceCpu` — this node only + /// supports hardware decoding. Capability probing is deferred to + /// `run()`. + pub fn new(config: VulkanVideoH264DecoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "VulkanVideoH264DecoderNode only supports hardware decoding; \ + use an OpenH264 decoder for CPU-only mode" + .to_string(), + )); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for VulkanVideoH264DecoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::H264, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + })], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + async fn run(self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { + let node_name = context.output_sender.node_name().to_string(); + state_helpers::emit_initializing(&context.state_tx, &node_name); + + tracing::info!("VulkanVideoH264DecoderNode starting (hw_accel={:?})", self.config.hw_accel); + let mut input_rx = context.take_input("in")?; + let video_pool = context.video_pool.clone(); + + // ── Metrics ────────────────────────────────────────────────────── + let meter = global::meter("skit_nodes"); + let packets_processed_counter = + meter.u64_counter("vulkan_video_h264_decoder_packets_processed").build(); + let decode_duration_histogram = meter + .f64_histogram("vulkan_video_h264_decode_duration") + .with_boundaries(streamkit_core::metrics::HISTOGRAM_BOUNDARIES_CODEC_PACKET.to_vec()) + .build(); + + // ── Channels ───────────────────────────────────────────────────── + let (decode_tx, mut decode_rx) = + mpsc::channel::<(Bytes, Option)>(get_codec_channel_capacity()); + let (result_tx, mut result_rx) = + mpsc::channel::>(get_codec_channel_capacity()); + + // ── Blocking decode task ───────────────────────────────────────── + let decode_task = tokio::task::spawn_blocking(move || { + let instance = match vk_video::VulkanInstance::new() { + Ok(inst) => inst, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("failed to create VulkanInstance: {err}"))); + return; + }, + }; + + let adapter = match instance + .create_adapter(&vk_video::parameters::VulkanAdapterDescriptor::default()) + { + Ok(a) => a, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("failed to create VulkanAdapter: {err}"))); + return; + }, + }; + + let device = match adapter + .create_device(&vk_video::parameters::VulkanDeviceDescriptor::default()) + { + Ok(d) => d, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("failed to create VulkanDevice: {err}"))); + return; + }, + }; + + if !device.supports_decoding() { + let _ = result_tx.blocking_send(Err( + "Vulkan device does not support video decoding".to_string(), + )); + return; + } + + let mut decoder = match device + .create_bytes_decoder(vk_video::parameters::DecoderParameters::default()) + { + Ok(dec) => dec, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("failed to create BytesDecoder: {err}"))); + return; + }, + }; + + tracing::info!("Vulkan Video H.264 decoder initialised successfully"); + + while let Some((data, metadata)) = decode_rx.blocking_recv() { + if result_tx.is_closed() { + return; + } + + let pts = metadata.as_ref().and_then(|m| m.timestamp_us); + + let decode_start = Instant::now(); + let decode_result = + decoder.decode(vk_video::EncodedInputChunk { data: &data, pts }); + decode_duration_histogram.record(decode_start.elapsed().as_secs_f64(), &[]); + + match decode_result { + Ok(frames) => { + for output_frame in frames { + match raw_frame_to_video_frame( + &output_frame, + metadata.clone(), + video_pool.as_ref(), + ) { + Ok(vf) => { + if result_tx.blocking_send(Ok(vf)).is_err() { + return; + } + }, + Err(err) => { + let _ = result_tx.blocking_send(Err(err)); + }, + } + } + }, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("Vulkan Video H.264 decode error: {err}"))); + }, + } + } + + // Flush remaining buffered frames. + if result_tx.is_closed() { + return; + } + match decoder.flush() { + Ok(frames) => { + for output_frame in frames { + match raw_frame_to_video_frame(&output_frame, None, video_pool.as_ref()) { + Ok(vf) => { + if result_tx.blocking_send(Ok(vf)).is_err() { + return; + } + }, + Err(err) => { + let _ = result_tx.blocking_send(Err(err)); + }, + } + } + }, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("Vulkan Video H.264 flush error: {err}"))); + }, + } + }); + + // ── State transition ───────────────────────────────────────────── + state_helpers::emit_running(&context.state_tx, &node_name); + let mut stats_tracker = NodeStatsTracker::new(node_name.clone(), context.stats_tx.clone()); + let batch_size = context.batch_size; + + // ── Input task ─────────────────────────────────────────────────── + let decode_tx_clone = decode_tx.clone(); + let mut input_task = tokio::spawn(async move { + loop { + let Some(first_packet) = input_rx.recv().await else { + break; + }; + + let packet_batch = + packet_helpers::batch_packets_greedy(first_packet, &mut input_rx, batch_size); + + for packet in packet_batch { + if let Packet::Binary { data, metadata, .. } = packet { + if decode_tx_clone.send((data, metadata)).await.is_err() { + tracing::error!( + "VulkanVideoH264DecoderNode decode task has shut down unexpectedly" + ); + return; + } + } + } + } + tracing::info!("VulkanVideoH264DecoderNode input stream closed"); + }); + + // ── Forward loop ───────────────────────────────────────────────── + crate::codec_utils::codec_forward_loop( + &mut context, + &mut result_rx, + &mut input_task, + decode_task, + decode_tx, + &packets_processed_counter, + &mut stats_tracker, + Packet::Video, + "VulkanVideoH264DecoderNode", + ) + .await; + + state_helpers::emit_stopped(&context.state_tx, &node_name, "input_closed"); + tracing::info!("VulkanVideoH264DecoderNode finished"); + Ok(()) + } +} + +/// Convert a vk-video `OutputFrame` into a StreamKit `VideoFrame`. +fn raw_frame_to_video_frame( + output_frame: &vk_video::OutputFrame, + metadata: Option, + video_pool: Option<&Arc>, +) -> Result { + let raw = &output_frame.data; + let nv12_bytes = &raw.frame; + let width = raw.width; + let height = raw.height; + + let layout = VideoLayout::packed(width, height, PixelFormat::Nv12); + let expected_bytes = layout.total_bytes(); + + if nv12_bytes.len() < expected_bytes { + return Err(format!( + "Vulkan Video decoder returned {len} bytes but NV12 {width}×{height} needs {expected_bytes}", + len = nv12_bytes.len(), + )); + } + + let mut data = video_pool.map_or_else( + || PooledVideoData::from_vec(vec![0u8; expected_bytes]), + |pool| pool.get(expected_bytes), + ); + data.as_mut_slice()[..expected_bytes].copy_from_slice(&nv12_bytes[..expected_bytes]); + + let frame_metadata = metadata.map(|mut m| { + // Propagate PTS from vk-video if the incoming metadata had none. + if m.timestamp_us.is_none() { + m.timestamp_us = output_frame.metadata.pts; + } + m + }); + + Ok(VideoFrame { + data: Arc::new(data), + pixel_format: PixelFormat::Nv12, + width, + height, + layout, + metadata: frame_metadata, + }) +} + +// --------------------------------------------------------------------------- +// Encoder +// --------------------------------------------------------------------------- + +/// Configuration for the Vulkan Video H.264 encoder node. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct VulkanVideoH264EncoderConfig { + /// Hardware acceleration mode. + pub hw_accel: HwAccelMode, + /// Target bitrate in bits per second. + pub bitrate: u32, + /// Maximum bitrate in bits per second (VBR mode). + /// Defaults to 4× the target bitrate. + pub max_bitrate: Option, + /// Target framerate (frames per second). + pub framerate: u32, +} + +impl Default for VulkanVideoH264EncoderConfig { + fn default() -> Self { + Self { hw_accel: HwAccelMode::Auto, bitrate: 2_000_000, max_bitrate: None, framerate: 30 } + } +} + +/// Vulkan Video H.264 encoder node. +/// +/// Accepts NV12/I420 `VideoFrame`s on its `"in"` pin and emits H.264 +/// encoded `Binary` packets on its `"out"` pin. +/// +/// Internally uses `vk-video::BytesEncoder` for GPU-accelerated encoding. +/// I420 input is converted to NV12 before encoding since Vulkan Video +/// operates on NV12. +pub struct VulkanVideoH264EncoderNode { + config: VulkanVideoH264EncoderConfig, +} + +impl VulkanVideoH264EncoderNode { + /// Create a new encoder node with the given configuration. + /// + /// # Errors + /// + /// Returns an error if `hw_accel` is `ForceCpu` — this node only + /// supports hardware encoding. Also rejects zero bitrate or + /// framerate to avoid confusing hardware-level errors later. + pub fn new(config: VulkanVideoH264EncoderConfig) -> Result { + if matches!(config.hw_accel, HwAccelMode::ForceCpu) { + return Err(StreamKitError::Configuration( + "VulkanVideoH264EncoderNode only supports hardware encoding; \ + use an OpenH264 encoder for CPU-only mode" + .to_string(), + )); + } + if config.bitrate == 0 { + return Err(StreamKitError::Configuration( + "VulkanVideoH264EncoderNode: bitrate must be > 0".to_string(), + )); + } + if config.framerate == 0 { + return Err(StreamKitError::Configuration( + "VulkanVideoH264EncoderNode: framerate must be > 0".to_string(), + )); + } + Ok(Self { config }) + } +} + +#[async_trait] +impl ProcessorNode for VulkanVideoH264EncoderNode { + fn input_pins(&self) -> Vec { + vec![InputPin { + name: "in".to_string(), + accepts_types: vec![ + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::Nv12, + }), + PacketType::RawVideo(RawVideoFormat { + width: None, + height: None, + pixel_format: PixelFormat::I420, + }), + ], + cardinality: PinCardinality::One, + }] + } + + fn output_pins(&self) -> Vec { + vec![OutputPin { + name: "out".to_string(), + produces_type: PacketType::EncodedVideo(EncodedVideoFormat { + codec: VideoCodec::H264, + bitstream_format: None, + codec_private: None, + profile: None, + level: None, + }), + cardinality: PinCardinality::Broadcast, + }] + } + + fn content_type(&self) -> Option { + Some(H264_CONTENT_TYPE.to_string()) + } + + async fn run(self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { + let node_name = context.output_sender.node_name().to_string(); + state_helpers::emit_initializing(&context.state_tx, &node_name); + + tracing::info!( + "VulkanVideoH264EncoderNode starting (hw_accel={:?}, bitrate={})", + self.config.hw_accel, + self.config.bitrate, + ); + let mut input_rx = context.take_input("in")?; + + // ── Metrics ────────────────────────────────────────────────────── + let meter = global::meter("skit_nodes"); + let packets_processed_counter = + meter.u64_counter("vulkan_video_h264_encoder_packets_processed").build(); + let encode_duration_histogram = meter + .f64_histogram("vulkan_video_h264_encode_duration") + .with_boundaries(streamkit_core::metrics::HISTOGRAM_BOUNDARIES_CODEC_PACKET.to_vec()) + .build(); + + // ── Channels ───────────────────────────────────────────────────── + let (encode_tx, mut encode_rx) = + mpsc::channel::<(VideoFrame, Option)>(get_codec_channel_capacity()); + let (result_tx, mut result_rx) = + mpsc::channel::>(get_codec_channel_capacity()); + + // ── Pre-initialise Vulkan device ───────────────────────────────── + // Eagerly create the Vulkan device so the blocking encode task can + // start processing frames immediately. Without this, device + // creation (~500 ms on some GPUs) blocks the encode loop and + // causes short/fast pipelines (e.g. colorbars with 30 frames) to + // produce zero output — the input stream closes before the encoder + // is ready. + let pre_init_device = tokio::task::spawn_blocking(|| init_vulkan_encode_device(None)) + .await + .map_err(|e| StreamKitError::Runtime(format!("Vulkan device init task panicked: {e}")))? + .map_err(StreamKitError::Runtime)?; + + // ── Blocking encode task ───────────────────────────────────────── + // NOTE: This encoder does NOT use StandardVideoEncoder / + // spawn_standard_encode_task() because: + // 1. vk-video's BytesEncoder has no flush() method, which + // StandardVideoEncoder::flush_encoder() requires. + // 2. The Vulkan device needs eager pre-initialisation (~500 ms on + // some GPUs) before the encode loop starts — not supported by + // spawn_standard_encode_task's lazy-create model. + // 3. Dimension changes re-use the pre-initialised device without + // flushing (no frames are buffered internally). + // See encoder_trait.rs for the standard pattern used by VP9, AV1, + // NVENC, and VA-API encoders. + let config = self.config.clone(); + let encode_task = tokio::task::spawn_blocking(move || { + // The BytesEncoder is lazily created on the first frame (so we + // know the actual resolution), but the Vulkan device is already + // initialised above to avoid blocking frame reception. + let mut encoder: Option = None; + let device: Arc = pre_init_device; + let mut current_dimensions: Option<(u32, u32)> = None; + let mut frames_encoded: u64 = 0; + + while let Some((frame, metadata)) = encode_rx.blocking_recv() { + if result_tx.is_closed() { + return; + } + + let dims = (frame.width, frame.height); + + // (Re-)create encoder when dimensions change. + if current_dimensions != Some(dims) { + tracing::info!( + "VulkanVideoH264EncoderNode: (re)creating encoder for {}×{}", + dims.0, + dims.1, + ); + + let max_bitrate = u64::from( + config.max_bitrate.unwrap_or_else(|| config.bitrate.saturating_mul(4)), + ); + + let output_params = match device.encoder_output_parameters_high_quality( + vk_video::parameters::RateControl::VariableBitrate { + average_bitrate: u64::from(config.bitrate), + max_bitrate, + virtual_buffer_size: Duration::from_secs(2), + }, + ) { + Ok(p) => p, + Err(err) => { + let _ = result_tx.blocking_send(Err(format!( + "failed to get encoder output parameters: {err}" + ))); + return; + }, + }; + + let width = NonZeroU32::new(dims.0).unwrap_or(NonZeroU32::MIN); + let height = NonZeroU32::new(dims.1).unwrap_or(NonZeroU32::MIN); + + let enc = + match device.create_bytes_encoder(vk_video::parameters::EncoderParameters { + input_parameters: vk_video::parameters::VideoParameters { + width, + height, + target_framerate: config.framerate.into(), + }, + output_parameters: output_params, + }) { + Ok(e) => e, + Err(err) => { + let _ = result_tx.blocking_send(Err(format!( + "failed to create BytesEncoder: {err}" + ))); + return; + }, + }; + + encoder = Some(enc); + current_dimensions = Some(dims); + } + + let Some(enc) = encoder.as_mut() else { + let _ = result_tx.blocking_send(Err("encoder not initialised".to_string())); + return; + }; + + // Convert I420 → NV12 if necessary. + let nv12_data = match frame.pixel_format { + PixelFormat::Nv12 => frame.data.as_slice().to_vec(), + PixelFormat::I420 => super::i420_frame_to_nv12_buffer(&frame), + other => { + let _ = result_tx.blocking_send(Err(format!( + "VulkanVideoH264EncoderNode: unsupported pixel format {other:?}, \ + expected NV12 or I420" + ))); + continue; + }, + }; + + // The first frame MUST be an IDR so that downstream + // muxers (MP4 in particular gates on the first keyframe) + // and players can initialise correctly. + let force_keyframe = frames_encoded == 0 + || metadata.as_ref().and_then(|m| m.keyframe).unwrap_or(false); + + let input_frame = vk_video::InputFrame { + data: vk_video::RawFrameData { + frame: nv12_data, + width: frame.width, + height: frame.height, + }, + pts: metadata.as_ref().and_then(|m| m.timestamp_us), + }; + + let encode_start = Instant::now(); + let result = enc.encode(&input_frame, force_keyframe); + encode_duration_histogram.record(encode_start.elapsed().as_secs_f64(), &[]); + + match result { + Ok(encoded_chunk) => { + frames_encoded += 1; + // Always propagate the keyframe flag, even when + // the input had no metadata. Without this, + // downstream RTMP/MoQ transport cannot detect + // keyframes for stream initialisation. + let out_meta = match metadata { + Some(mut m) => { + m.keyframe = Some(encoded_chunk.is_keyframe); + Some(m) + }, + None => Some(PacketMetadata { + timestamp_us: None, + duration_us: None, + sequence: None, + keyframe: Some(encoded_chunk.is_keyframe), + }), + }; + + let output = EncoderOutput { + data: Bytes::from(encoded_chunk.data), + metadata: out_meta, + }; + if result_tx.blocking_send(Ok(output)).is_err() { + tracing::debug!("VulkanVideoH264EncoderNode result channel closed after {frames_encoded} frame(s)"); + return; + } + }, + Err(err) => { + let _ = result_tx + .blocking_send(Err(format!("Vulkan Video H.264 encode error: {err}"))); + }, + } + } + + // Note: vk-video 0.3.0's BytesEncoder has no flush() method + // (unlike BytesDecoder which does). The encoder operates + // frame-at-a-time without B-frame reordering, so no frames + // should be buffered internally. If a future vk-video version + // adds flush(), it should be called here — matching the + // decoder's flush at line ~245 and the pattern in + // encoder_trait::spawn_standard_encode_task. + tracing::info!( + "VulkanVideoH264EncoderNode encode task finished after {frames_encoded} frame(s)" + ); + }); + + // ── State transition ───────────────────────────────────────────── + state_helpers::emit_running(&context.state_tx, &node_name); + let mut stats_tracker = NodeStatsTracker::new(node_name.clone(), context.stats_tx.clone()); + let batch_size = context.batch_size; + + // ── Input task ─────────────────────────────────────────────────── + let encode_tx_clone = encode_tx.clone(); + let node_label = "VulkanVideoH264EncoderNode"; + let mut input_task = tokio::spawn(async move { + loop { + let Some(first_packet) = input_rx.recv().await else { + break; + }; + + let packet_batch = + packet_helpers::batch_packets_greedy(first_packet, &mut input_rx, batch_size); + + for packet in packet_batch { + if let Packet::Video(mut frame) = packet { + let metadata = frame.metadata.take(); + if encode_tx_clone.send((frame, metadata)).await.is_err() { + tracing::error!("{node_label} encode task has shut down unexpectedly"); + return; + } + } + } + } + tracing::info!("{node_label} input stream closed"); + }); + + // ── Forward loop ───────────────────────────────────────────────── + crate::codec_utils::codec_forward_loop( + &mut context, + &mut result_rx, + &mut input_task, + encode_task, + encode_tx, + &packets_processed_counter, + &mut stats_tracker, + |encoded: EncoderOutput| Packet::Binary { + data: encoded.data, + content_type: Some(Cow::Borrowed(H264_CONTENT_TYPE)), + metadata: encoded.metadata, + }, + node_label, + ) + .await; + + state_helpers::emit_stopped(&context.state_tx, &node_name, "input_closed"); + tracing::info!("VulkanVideoH264EncoderNode finished"); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Encoder helpers +// --------------------------------------------------------------------------- + +/// Internal encoded output type for the encoder channel. +struct EncoderOutput { + data: Bytes, + metadata: Option, +} + +/// Initialise (or reuse) the Vulkan device for encoding. +fn init_vulkan_encode_device( + existing: Option<&Arc>, +) -> Result, String> { + if let Some(dev) = existing { + return Ok(Arc::clone(dev)); + } + + let instance = vk_video::VulkanInstance::new() + .map_err(|e| format!("failed to create VulkanInstance: {e}"))?; + + let adapter = instance + .create_adapter(&vk_video::parameters::VulkanAdapterDescriptor::default()) + .map_err(|e| format!("failed to create VulkanAdapter: {e}"))?; + + let device = adapter + .create_device(&vk_video::parameters::VulkanDeviceDescriptor::default()) + .map_err(|e| format!("failed to create VulkanDevice: {e}"))?; + + if !device.supports_encoding() { + return Err("Vulkan device does not support video encoding".to_string()); + } + + tracing::info!("Vulkan Video encode device initialised successfully"); + Ok(device) +} + +// I420→NV12 conversion is now in super::i420_frame_to_nv12_buffer(). + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +use schemars::schema_for; +use streamkit_core::registry::StaticPins; + +#[allow(clippy::expect_used, clippy::missing_panics_doc)] +pub fn register_vulkan_video_nodes(registry: &mut NodeRegistry) { + let default_decoder = VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig::default()) + .expect("default VulkanVideoH264 decoder config should be valid"); + registry.register_static_with_description( + "video::vulkan_video::h264_decoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(VulkanVideoH264DecoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(VulkanVideoH264DecoderConfig)) + .expect("VulkanVideoH264DecoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_decoder.input_pins(), outputs: default_decoder.output_pins() }, + vec!["video".to_string(), "codecs".to_string(), "h264".to_string(), "hw".to_string()], + false, + "Decodes H.264 Annex B packets into raw NV12 video frames using Vulkan Video \ + hardware acceleration. Requires a GPU with Vulkan Video decode support \ + (NVIDIA, AMD, or Intel with recent Mesa drivers). Use video::openh264::decoder \ + for CPU-only fallback.", + ); + + let default_encoder = VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()) + .expect("default VulkanVideoH264 encoder config should be valid"); + registry.register_static_with_description( + "video::vulkan_video::h264_encoder", + |params| { + let config = config_helpers::parse_config_optional(params)?; + Ok(Box::new(VulkanVideoH264EncoderNode::new(config)?)) + }, + serde_json::to_value(schema_for!(VulkanVideoH264EncoderConfig)) + .expect("VulkanVideoH264EncoderConfig schema should serialize to JSON"), + StaticPins { inputs: default_encoder.input_pins(), outputs: default_encoder.output_pins() }, + vec!["video".to_string(), "codecs".to_string(), "h264".to_string(), "hw".to_string()], + false, + "Encodes raw video frames (NV12 or I420) into H.264 Annex B packets using \ + Vulkan Video hardware acceleration. Supports VBR rate control with configurable \ + bitrate. Requires a GPU with Vulkan Video encode support. Use \ + video::openh264::encoder for CPU-only fallback.", + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_macros)] +mod tests { + use super::*; + use crate::test_utils::{ + assert_state_initializing, assert_state_running, assert_state_stopped, create_test_context, + create_test_video_frame, + }; + use std::collections::HashMap; + use streamkit_core::types::Packet; + use tokio::sync::mpsc; + + // ── Vulkan Video availability helper ──────────────────────────────── + // + // Integration tests that require a Vulkan Video capable GPU use this + // helper. On machines without the right hardware/drivers the tests + // print a message and pass (skip) instead of failing. + + /// Try to create a Vulkan Video device. Returns `true` if both encode + /// and decode are available. + fn vulkan_video_available() -> bool { + let Ok(instance) = vk_video::VulkanInstance::new() else { + return false; + }; + let Ok(adapter) = + instance.create_adapter(&vk_video::parameters::VulkanAdapterDescriptor::default()) + else { + return false; + }; + let Ok(device) = + adapter.create_device(&vk_video::parameters::VulkanDeviceDescriptor::default()) + else { + return false; + }; + device.supports_decoding() && device.supports_encoding() + } + + /// Like [`vulkan_video_available`] but only checks for decode support. + fn vulkan_decode_available() -> bool { + let Ok(instance) = vk_video::VulkanInstance::new() else { + return false; + }; + let Ok(adapter) = + instance.create_adapter(&vk_video::parameters::VulkanAdapterDescriptor::default()) + else { + return false; + }; + let Ok(device) = + adapter.create_device(&vk_video::parameters::VulkanDeviceDescriptor::default()) + else { + return false; + }; + device.supports_decoding() + } + + /// Like [`vulkan_video_available`] but only checks for encode support. + fn vulkan_encode_available() -> bool { + let Ok(instance) = vk_video::VulkanInstance::new() else { + return false; + }; + let Ok(adapter) = + instance.create_adapter(&vk_video::parameters::VulkanAdapterDescriptor::default()) + else { + return false; + }; + let Ok(device) = + adapter.create_device(&vk_video::parameters::VulkanDeviceDescriptor::default()) + else { + return false; + }; + device.supports_encoding() + } + + macro_rules! skip_without_vulkan_encode { + () => { + if !vulkan_encode_available() { + eprintln!("SKIPPED: no Vulkan Video encode support on this machine"); + return; + } + }; + } + + macro_rules! skip_without_vulkan_decode { + () => { + if !vulkan_decode_available() { + eprintln!("SKIPPED: no Vulkan Video decode support on this machine"); + return; + } + }; + } + + macro_rules! skip_without_vulkan_video { + () => { + if !vulkan_video_available() { + eprintln!("SKIPPED: no Vulkan Video encode+decode support on this machine"); + return; + } + }; + } + + // ── Config validation tests (no GPU needed) ───────────────────────── + + #[test] + fn test_decoder_rejects_force_cpu() { + let result = VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig { + hw_accel: HwAccelMode::ForceCpu, + }); + assert!(result.is_err(), "ForceCpu should be rejected for HW-only decoder"); + } + + #[test] + fn test_decoder_accepts_auto() { + let result = VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig { + hw_accel: HwAccelMode::Auto, + }); + assert!(result.is_ok(), "Auto should be accepted"); + } + + #[test] + fn test_decoder_accepts_force_hw() { + let result = VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig { + hw_accel: HwAccelMode::ForceHw, + }); + assert!(result.is_ok(), "ForceHw should be accepted"); + } + + #[test] + fn test_encoder_rejects_force_cpu() { + let result = VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig { + hw_accel: HwAccelMode::ForceCpu, + ..Default::default() + }); + assert!(result.is_err(), "ForceCpu should be rejected for HW-only encoder"); + } + + #[test] + fn test_encoder_rejects_zero_bitrate() { + let result = VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig { + bitrate: 0, + ..Default::default() + }); + assert!(result.is_err(), "bitrate=0 should be rejected"); + } + + #[test] + fn test_encoder_rejects_zero_framerate() { + let result = VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig { + framerate: 0, + ..Default::default() + }); + assert!(result.is_err(), "framerate=0 should be rejected"); + } + + #[test] + fn test_encoder_accepts_valid_config() { + let result = VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig { + hw_accel: HwAccelMode::Auto, + bitrate: 2_000_000, + max_bitrate: None, + framerate: 30, + }); + assert!(result.is_ok(), "valid config should be accepted"); + } + + #[test] + fn test_encoder_accepts_custom_max_bitrate() { + let result = VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig { + hw_accel: HwAccelMode::Auto, + bitrate: 2_000_000, + max_bitrate: Some(8_000_000), + framerate: 60, + }); + assert!(result.is_ok(), "custom max_bitrate config should be accepted"); + } + + // ── deny_unknown_fields tests ───────────────────────────────────── + + #[test] + fn test_deny_unknown_fields_decoder() { + let json = r#"{"hw_accel":"auto","bogus_field":42}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "Unknown fields should be rejected"); + } + + #[test] + fn test_deny_unknown_fields_encoder() { + let json = r#"{"bitrate":1000000,"unknown_key":"oops"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "Unknown fields should be rejected"); + } + + // ── Pin configuration tests ───────────────────────────────────────── + + #[test] + fn test_decoder_pin_config() { + let node = + VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig::default()).unwrap(); + + let inputs = node.input_pins(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].name, "in"); + assert!(matches!(inputs[0].cardinality, PinCardinality::One)); + assert!(matches!( + &inputs[0].accepts_types[0], + PacketType::EncodedVideo(fmt) if fmt.codec == VideoCodec::H264 + )); + + let outputs = node.output_pins(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].name, "out"); + assert!(matches!(outputs[0].cardinality, PinCardinality::Broadcast)); + assert!(matches!( + &outputs[0].produces_type, + PacketType::RawVideo(fmt) if fmt.pixel_format == PixelFormat::Nv12 + )); + } + + #[test] + fn test_encoder_pin_config() { + let node = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + + let inputs = node.input_pins(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].name, "in"); + assert_eq!(inputs[0].accepts_types.len(), 2, "should accept NV12 and I420"); + + let outputs = node.output_pins(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].name, "out"); + assert!(matches!( + &outputs[0].produces_type, + PacketType::EncodedVideo(fmt) if fmt.codec == VideoCodec::H264 + )); + } + + #[test] + fn test_encoder_content_type() { + let node = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + assert_eq!( + node.content_type().as_deref(), + Some(H264_CONTENT_TYPE), + "Encoder should report video/h264 content type" + ); + } + + // ── Integration tests (require Vulkan Video GPU) ──────────────────── + + #[tokio::test] + async fn test_vulkan_video_encode_nv12() { + skip_without_vulkan_encode!(); + + let (input_tx, input_rx) = mpsc::channel(10); + let mut inputs = HashMap::new(); + inputs.insert("in".to_string(), input_rx); + + let (context, sender, mut state_rx) = create_test_context(inputs, 10); + let encoder = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + + let handle = tokio::spawn(async move { Box::new(encoder).run(context).await }); + + assert_state_initializing(&mut state_rx).await; + assert_state_running(&mut state_rx).await; + + for i in 0_u64..5 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * i), + duration_us: Some(33_333), + sequence: Some(i), + keyframe: Some(i == 0), + }); + input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(input_tx); + + assert_state_stopped(&mut state_rx).await; + handle.await.unwrap().unwrap(); + + let packets = sender.get_packets_for_pin("out").await; + assert!(!packets.is_empty(), "Vulkan Video encoder should produce packets"); + + for (i, packet) in packets.iter().enumerate() { + match packet { + Packet::Binary { data, content_type, metadata, .. } => { + assert!(!data.is_empty(), "Encoded packet {i} should have data"); + assert_eq!( + content_type.as_deref(), + Some(H264_CONTENT_TYPE), + "Content type should be video/h264" + ); + assert!(metadata.is_some(), "Encoded packet {i} should have metadata"); + let meta = metadata.as_ref().unwrap(); + assert!( + meta.keyframe.is_some(), + "Encoded packet {i} should have keyframe flag" + ); + }, + _ => panic!("Expected Binary packet from Vulkan Video encoder, got {packet:?}"), + } + } + } + + #[tokio::test] + async fn test_vulkan_video_encode_i420() { + skip_without_vulkan_encode!(); + + let (input_tx, input_rx) = mpsc::channel(10); + let mut inputs = HashMap::new(); + inputs.insert("in".to_string(), input_rx); + + let (context, sender, mut state_rx) = create_test_context(inputs, 10); + let encoder = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + + let handle = tokio::spawn(async move { Box::new(encoder).run(context).await }); + + assert_state_initializing(&mut state_rx).await; + assert_state_running(&mut state_rx).await; + + for i in 0_u64..3 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::I420, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * i), + duration_us: Some(33_333), + sequence: Some(i), + keyframe: Some(true), + }); + input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(input_tx); + + assert_state_stopped(&mut state_rx).await; + handle.await.unwrap().unwrap(); + + let packets = sender.get_packets_for_pin("out").await; + assert!(!packets.is_empty(), "Vulkan Video encoder should produce packets from I420 input"); + } + + #[tokio::test] + async fn test_vulkan_video_encode_metadata_without_input_metadata() { + skip_without_vulkan_encode!(); + + let (input_tx, input_rx) = mpsc::channel(10); + let mut inputs = HashMap::new(); + inputs.insert("in".to_string(), input_rx); + + let (context, sender, mut state_rx) = create_test_context(inputs, 10); + let encoder = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + + let handle = tokio::spawn(async move { Box::new(encoder).run(context).await }); + + assert_state_initializing(&mut state_rx).await; + assert_state_running(&mut state_rx).await; + + // Send frames with NO metadata to verify keyframe flag is still propagated. + for _ in 0..3 { + let frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + // frame.metadata is None by default from create_test_video_frame + input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(input_tx); + + assert_state_stopped(&mut state_rx).await; + handle.await.unwrap().unwrap(); + + let packets = sender.get_packets_for_pin("out").await; + assert!(!packets.is_empty(), "Encoder should produce packets even without input metadata"); + + for (i, packet) in packets.iter().enumerate() { + match packet { + Packet::Binary { metadata, .. } => { + assert!( + metadata.is_some(), + "Packet {i} should have metadata even when input had None" + ); + let meta = metadata.as_ref().unwrap(); + assert!( + meta.keyframe.is_some(), + "Packet {i} should always have keyframe flag set" + ); + }, + _ => panic!("Expected Binary packet"), + } + } + } + + #[tokio::test] + async fn test_vulkan_video_roundtrip_encode_decode() { + skip_without_vulkan_video!(); + + // ── Step 1: Encode NV12 frames to H.264 ───────────────────────── + let (enc_input_tx, enc_input_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_input_rx); + + let (enc_context, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_context).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + let frame_count = 5_u64; + let width = 64_u32; + let height = 64_u32; + + for i in 0..frame_count { + let mut frame = create_test_video_frame(width, height, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * i), + duration_us: Some(33_333), + sequence: Some(i), + keyframe: Some(i == 0), + }); + enc_input_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_input_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "Encoder should produce packets"); + + // ── Step 2: Decode the H.264 packets back to NV12 ─────────────── + let (dec_input_tx, dec_input_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_input_rx); + + let (dec_context, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = + VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig::default()).unwrap(); + + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_context).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + // Feed encoded packets to the decoder. + for packet in encoded_packets { + dec_input_tx.send(packet).await.unwrap(); + } + drop(dec_input_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "Decoder should produce frames from roundtrip data"); + + // Verify decoded frames are NV12 with the right dimensions. + for (i, packet) in decoded_packets.iter().enumerate() { + match packet { + Packet::Video(frame) => { + assert_eq!( + frame.pixel_format, + PixelFormat::Nv12, + "Decoded frame {i} should be NV12" + ); + assert_eq!(frame.width, width, "Decoded frame {i} width mismatch"); + assert_eq!(frame.height, height, "Decoded frame {i} height mismatch"); + assert!( + !frame.data.as_slice().is_empty(), + "Decoded frame {i} should have data" + ); + }, + _ => panic!("Expected Video packet from decoder, got {packet:?}"), + } + } + } + + // ── I420→NV12 conversion unit test ────────────────────────────────── + + #[test] + fn test_i420_to_nv12_conversion() { + let width = 4_u32; + let height = 4_u32; + let frame = create_test_video_frame(width, height, PixelFormat::I420, 0); + + // Manually fill planes with known values for verification. + let layout = frame.layout(); + let planes = layout.planes(); + + // Build a frame with identifiable plane content. + let mut data = vec![0u8; layout.total_bytes()]; + // Y plane: fill with 100 + for row in 0..height as usize { + for col in 0..width as usize { + data[planes[0].offset + row * planes[0].stride + col] = 100; + } + } + // U plane: fill with 50 + let chroma_w = width as usize / 2; + let chroma_h = height as usize / 2; + for row in 0..chroma_h { + for col in 0..chroma_w { + data[planes[1].offset + row * planes[1].stride + col] = 50; + } + } + // V plane: fill with 200 + for row in 0..chroma_h { + for col in 0..chroma_w { + data[planes[2].offset + row * planes[2].stride + col] = 200; + } + } + + let test_frame = VideoFrame::new(width, height, PixelFormat::I420, data) + .expect("test frame should be valid"); + + let nv12 = crate::video::i420_frame_to_nv12_buffer(&test_frame); + + let y_size = (width * height) as usize; + let uv_size = width as usize * (height as usize / 2); + assert_eq!(nv12.len(), y_size + uv_size, "NV12 buffer size mismatch"); + + // Verify Y plane was copied correctly. + for (i, &byte) in nv12.iter().enumerate().take(y_size) { + assert_eq!(byte, 100, "Y plane byte {i} mismatch"); + } + + // Verify UV plane has interleaved U and V values. + for row in 0..chroma_h { + for col in 0..chroma_w { + let uv_offset = y_size + row * width as usize + col * 2; + assert_eq!(nv12[uv_offset], 50, "U value at row={row} col={col} mismatch"); + assert_eq!(nv12[uv_offset + 1], 200, "V value at row={row} col={col} mismatch"); + } + } + } + + // ── Standalone decode test (requires encode+decode to produce input) ─ + + #[tokio::test] + async fn test_vulkan_video_decode_produces_frames() { + // We need both encode (to generate H.264 data) and decode capabilities. + // Use skip_without_vulkan_decode for the decode-specific skip message, + // but we also need encode to produce test data. + skip_without_vulkan_decode!(); + skip_without_vulkan_encode!(); + + // First encode a few frames to get valid H.264 data. + let (enc_tx, enc_rx) = mpsc::channel(10); + let mut enc_inputs = HashMap::new(); + enc_inputs.insert("in".to_string(), enc_rx); + + let (enc_ctx, enc_sender, mut enc_state_rx) = create_test_context(enc_inputs, 10); + let encoder = + VulkanVideoH264EncoderNode::new(VulkanVideoH264EncoderConfig::default()).unwrap(); + let enc_handle = tokio::spawn(async move { Box::new(encoder).run(enc_ctx).await }); + + assert_state_initializing(&mut enc_state_rx).await; + assert_state_running(&mut enc_state_rx).await; + + for i in 0_u64..5 { + let mut frame = create_test_video_frame(64, 64, PixelFormat::Nv12, 16); + frame.metadata = Some(PacketMetadata { + timestamp_us: Some(33_333 * i), + duration_us: Some(33_333), + sequence: Some(i), + keyframe: Some(i == 0), + }); + enc_tx.send(Packet::Video(frame)).await.unwrap(); + } + drop(enc_tx); + + assert_state_stopped(&mut enc_state_rx).await; + enc_handle.await.unwrap().unwrap(); + + let encoded_packets = enc_sender.get_packets_for_pin("out").await; + assert!(!encoded_packets.is_empty(), "Need encoded data to test decoder"); + + // Now decode. + let (dec_tx, dec_rx) = mpsc::channel(10); + let mut dec_inputs = HashMap::new(); + dec_inputs.insert("in".to_string(), dec_rx); + + let (dec_ctx, dec_sender, mut dec_state_rx) = create_test_context(dec_inputs, 10); + let decoder = + VulkanVideoH264DecoderNode::new(VulkanVideoH264DecoderConfig::default()).unwrap(); + let dec_handle = tokio::spawn(async move { Box::new(decoder).run(dec_ctx).await }); + + assert_state_initializing(&mut dec_state_rx).await; + assert_state_running(&mut dec_state_rx).await; + + for packet in encoded_packets { + dec_tx.send(packet).await.unwrap(); + } + drop(dec_tx); + + assert_state_stopped(&mut dec_state_rx).await; + dec_handle.await.unwrap().unwrap(); + + let decoded_packets = dec_sender.get_packets_for_pin("out").await; + assert!(!decoded_packets.is_empty(), "Decoder should produce NV12 frames"); + + for (i, packet) in decoded_packets.iter().enumerate() { + match packet { + Packet::Video(frame) => { + assert_eq!( + frame.pixel_format, + PixelFormat::Nv12, + "Decoded frame {i} should be NV12" + ); + assert_eq!(frame.width, 64, "Decoded frame {i} width mismatch"); + assert_eq!(frame.height, 64, "Decoded frame {i} height mismatch"); + }, + _ => panic!("Expected Video packet from decoder"), + } + } + } + + // ── Registration test ─────────────────────────────────────────────── + + #[test] + fn test_node_registration() { + let mut registry = NodeRegistry::new(); + register_vulkan_video_nodes(&mut registry); + + // Verify both nodes are registered by trying to create them with + // default config. + assert!( + registry.create_node("video::vulkan_video::h264_decoder", None).is_ok(), + "decoder should be registered" + ); + assert!( + registry.create_node("video::vulkan_video::h264_encoder", None).is_ok(), + "encoder should be registered" + ); + } +} diff --git a/justfile b/justfile index be8c6ee4..0ee45f02 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,8 @@ tokio_console_features := "--features tokio-console" # Optional extra features to enable in skit builds (e.g. "svt_av1"). # Usage: just extra_features="--features svt_av1" skit # or: just extra_features="--features svt_av1" build-skit +# HW codecs: vulkan_video (H.264 Vulkan Video), vaapi (AV1 VA-API), nvcodec (AV1 NVENC/NVDEC) +# e.g.: just extra_features="--features vulkan_video,nvcodec" skit extra_features := "" # sherpa-onnx version for Kokoro TTS plugin (must match sherpa-rs version) @@ -216,11 +218,12 @@ test-skit: @cargo test --workspace -- --skip gpu_tests:: @cargo test -p streamkit-server --features "moq" -# Run GPU compositor tests (requires a machine with a GPU) +# Run GPU tests (requires a machine with a GPU) test-skit-gpu: @echo "Testing skit (GPU)..." @cargo test -p streamkit-nodes --features gpu @cargo test -p streamkit-engine --features gpu + @cargo test -p streamkit-nodes --features nvcodec # Lint and format check the skit code # Note: We exclude dhat-heap since it's mutually exclusive with profiling (both define global allocators) @@ -1386,6 +1389,18 @@ e2e-external url filter='': @echo "Running E2E tests against {{url}}..." @cd e2e && E2E_BASE_URL={{url}} bun run test:only {{ if filter != "" { "--grep '" + filter + "'" } else { "" } }} +# Run headless pipeline validation tests (no browser required). +# Requires a running skit server — pass its URL as an argument. +# Each .yml in samples/pipelines/test/ becomes a test case; a companion +# .toml sidecar declares the expected ffprobe output. +# +# Usage: +# just test-pipelines http://localhost:4545 +# just test-pipelines http://localhost:4545 vp9 # filter by name +test-pipelines url filter='': + @echo "Running headless pipeline validation tests against {{url}}..." + @cd tests/pipeline-validation && PIPELINE_TEST_URL={{url}} cargo test --test validate {{ if filter != "" { "-- " + filter } else { "" } }} + # Show E2E test report [working-directory: 'e2e'] e2e-report: diff --git a/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml b/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml new file mode 100644 index 00000000..fb6572e8 --- /dev/null +++ b/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Streams SMPTE color bars encoded with NVIDIA NVENC AV1 (GPU-accelerated) +# over MoQ. +# +# Requires: skit built with --features nvcodec +# NVIDIA GPU with NVENC AV1 support (Ada Lovelace / RTX 40+) +# System packages: nvidia-cuda-toolkit, libclang-dev + +name: NVENC AV1 Color Bars (MoQ Stream) +description: Continuously generates SMPTE color bars and streams via MoQ using NVIDIA NVENC AV1 HW encoder +mode: dynamic +client: + gateway_path: /moq/video + watch: + broadcast: output + audio: false + video: true + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + pixel_format: nv12 + draw_time: true + + nv_av1_encoder: + kind: video::nv::av1_encoder + params: + bitrate: 2000000 + framerate: 30 + needs: colorbars + + moq_peer: + kind: transport::moq::peer + params: + gateway_path: /moq/video + output_broadcast: output + allow_reconnect: true + video_codec: av1 + needs: + in: nv_av1_encoder diff --git a/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml b/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml new file mode 100644 index 00000000..112f2345 --- /dev/null +++ b/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Streams SMPTE color bars encoded with VA-API AV1 (GPU-accelerated) over MoQ. +# +# Requires: skit built with --features vaapi +# VA-API capable GPU with AV1 encode support (Intel Arc+, AMD) +# System packages: libva-dev, libgbm-dev + +name: VA-API AV1 Color Bars (MoQ Stream) +description: Continuously generates SMPTE color bars and streams via MoQ using VA-API AV1 HW encoder +mode: dynamic +client: + gateway_path: /moq/video + watch: + broadcast: output + audio: false + video: true + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + pixel_format: nv12 + draw_time: true + + vaapi_av1_encoder: + kind: video::vaapi::av1_encoder + params: + quality: 128 + framerate: 30 + needs: colorbars + + moq_peer: + kind: transport::moq::peer + params: + gateway_path: /moq/video + output_broadcast: output + allow_reconnect: true + video_codec: av1 + needs: + in: vaapi_av1_encoder diff --git a/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml b/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml new file mode 100644 index 00000000..30a10638 --- /dev/null +++ b/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Streams SMPTE color bars encoded with VA-API H.264 (GPU-accelerated) over MoQ. +# +# Requires: skit built with --features vaapi +# VA-API capable GPU with H.264 encode support (Intel, AMD) +# System packages: libva-dev, libgbm-dev + +name: VA-API H.264 Color Bars (MoQ Stream) +description: Continuously generates SMPTE color bars and streams via MoQ using VA-API H.264 HW encoder +mode: dynamic +client: + gateway_path: /moq/video + watch: + broadcast: output + audio: false + video: true + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + pixel_format: nv12 + draw_time: true + + vaapi_h264_encoder: + kind: video::vaapi::h264_encoder + params: + quality: 26 + framerate: 30 + needs: colorbars + + moq_peer: + kind: transport::moq::peer + params: + gateway_path: /moq/video + output_broadcast: output + allow_reconnect: true + video_codec: h264 + needs: + in: vaapi_h264_encoder diff --git a/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml b/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml new file mode 100644 index 00000000..be381fdc --- /dev/null +++ b/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Streams SMPTE color bars encoded with Vulkan Video H.264 (GPU-accelerated) +# over MoQ. +# +# Requires: skit built with --features vulkan_video +# Vulkan-capable GPU with H.264 encode support + +name: Vulkan Video H.264 Color Bars (MoQ Stream) +description: Continuously generates SMPTE color bars and streams via MoQ using Vulkan Video H.264 HW encoder +mode: dynamic +client: + gateway_path: /moq/video + watch: + broadcast: output + audio: false + video: true + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + pixel_format: nv12 + draw_time: true + + vk_h264_encoder: + kind: video::vulkan_video::h264_encoder + params: + bitrate: 2000000 + framerate: 30 + needs: colorbars + + moq_peer: + kind: transport::moq::peer + params: + gateway_path: /moq/video + output_broadcast: output + allow_reconnect: true + video_codec: h264 + needs: + in: vk_h264_encoder diff --git a/samples/pipelines/oneshot/video_nv_av1_colorbars.yml b/samples/pipelines/oneshot/video_nv_av1_colorbars.yml new file mode 100644 index 00000000..dddddc21 --- /dev/null +++ b/samples/pipelines/oneshot/video_nv_av1_colorbars.yml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Demonstrates the NVIDIA NVENC AV1 HW encoder: +# Generates SMPTE color bars (NV12), encodes to AV1 via NVENC +# (GPU-accelerated), muxes into a WebM container, and writes the result +# to HTTP output. +# +# Requires: skit built with --features nvcodec +# NVIDIA GPU with NVENC AV1 support (Ada Lovelace / RTX 40+) +# System packages: nvidia-cuda-toolkit, libclang-dev + +name: NVENC AV1 Encode (WebM Oneshot) +description: Generates color bars, encodes to AV1 using NVIDIA NVENC HW encoder, and muxes into WebM (30 seconds) +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + frame_count: 900 # 30 seconds at 30fps + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + nv_av1_encoder: + kind: video::nv::av1_encoder + params: + bitrate: 2000000 + framerate: 30 + needs: colorbars + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 1280 + video_height: 720 + streaming_mode: live + needs: nv_av1_encoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="av1"' + needs: pacer diff --git a/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml b/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml new file mode 100644 index 00000000..9ac5d81c --- /dev/null +++ b/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Demonstrates the VA-API AV1 HW encoder: +# Generates SMPTE color bars (NV12), encodes to AV1 via VA-API +# (GPU-accelerated), muxes into a WebM container, and writes the result +# to HTTP output. +# +# Requires: skit built with --features vaapi +# VA-API capable GPU with AV1 encode support (Intel Arc+, AMD) +# System packages: libva-dev, libgbm-dev + +name: VA-API AV1 Encode (WebM Oneshot) +description: Generates color bars, encodes to AV1 using VA-API HW encoder, and muxes into WebM (30 seconds) +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + frame_count: 900 # 30 seconds at 30fps + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + vaapi_av1_encoder: + kind: video::vaapi::av1_encoder + params: + quality: 128 + framerate: 30 + needs: colorbars + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 1280 + video_height: 720 + streaming_mode: live + needs: vaapi_av1_encoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="av1"' + needs: pacer diff --git a/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml b/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml new file mode 100644 index 00000000..ac6f513d --- /dev/null +++ b/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Demonstrates the VA-API H.264 HW encoder: +# Generates SMPTE color bars (NV12), encodes to H.264 via VA-API +# (GPU-accelerated), muxes into an MP4 container, and writes the result +# to HTTP output. +# +# Requires: skit built with --features vaapi +# VA-API capable GPU with H.264 encode support (Intel, AMD) +# System packages: libva-dev, libgbm-dev + +name: VA-API H.264 Encode (MP4 Oneshot) +description: Generates color bars, encodes to H.264 using VA-API HW encoder, and muxes into MP4 (30 seconds) +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + frame_count: 900 # 30 seconds at 30fps + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + vaapi_h264_encoder: + kind: video::vaapi::h264_encoder + params: + quality: 26 + framerate: 30 + needs: colorbars + + mp4_muxer: + kind: containers::mp4::muxer + params: + mode: stream + video_width: 1280 + video_height: 720 + needs: vaapi_h264_encoder + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/mp4; codecs="avc1.4d0028"' + needs: mp4_muxer diff --git a/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml b/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml new file mode 100644 index 00000000..acbe95ba --- /dev/null +++ b/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Demonstrates the Vulkan Video H.264 HW encoder: +# Generates SMPTE color bars (NV12), encodes to H.264 via Vulkan Video +# (GPU-accelerated), muxes into an MP4 container, and writes the result +# to HTTP output. +# +# Requires: skit built with --features vulkan_video +# Vulkan-capable GPU with H.264 encode support + +name: Vulkan Video H.264 Encode (MP4 Oneshot) +description: Generates color bars, encodes to H.264 using Vulkan Video HW encoder, and muxes into MP4 (30 seconds) +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 1280 + height: 720 + fps: 30 + frame_count: 900 # 30 seconds at 30fps + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + vk_h264_encoder: + kind: video::vulkan_video::h264_encoder + params: + bitrate: 2000000 + framerate: 30 + needs: colorbars + + mp4_muxer: + kind: containers::mp4::muxer + params: + mode: stream + video_width: 1280 + video_height: 720 + needs: vk_h264_encoder + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/mp4; codecs="avc1.42c01f"' + needs: mp4_muxer diff --git a/samples/pipelines/test/dav1d_roundtrip/expected.toml b/samples/pipelines/test/dav1d_roundtrip/expected.toml new file mode 100644 index 00000000..dcb94a18 --- /dev/null +++ b/samples/pipelines/test/dav1d_roundtrip/expected.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +requires_node = "video::dav1d::decoder" +output_extension = ".webm" +codec_name = "av1" +width = 320 +height = 240 +container_format = "matroska,webm" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/dav1d_roundtrip/pipeline.yml b/samples/pipelines/test/dav1d_roundtrip/pipeline.yml new file mode 100644 index 00000000..ae3db3e9 --- /dev/null +++ b/samples/pipelines/test/dav1d_roundtrip/pipeline.yml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: SVT-AV1 encode → dav1d decode → SVT-AV1 re-encode roundtrip. +# Exercises: colorbars → svt_av1 encoder → dav1d decoder → svt_av1 re-encoder → webm muxer +# Used by headless pipeline validation tests. +# +# Requires: skit built with --features "svt_av1 dav1d" + +name: Test — dav1d AV1 Decode Roundtrip +description: AV1 roundtrip via SVT-AV1 encode → dav1d decode → SVT-AV1 re-encode +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + svt_av1_encoder: + kind: video::svt_av1::encoder + params: + preset: 12 + low_latency: true + needs: colorbars + + dav1d_decoder: + kind: video::dav1d::decoder + needs: svt_av1_encoder + + svt_av1_reencoder: + kind: video::svt_av1::encoder + params: + preset: 12 + low_latency: true + needs: dav1d_decoder + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 320 + video_height: 240 + streaming_mode: live + needs: svt_av1_reencoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="av1"' + needs: pacer diff --git a/samples/pipelines/test/fixtures/test_tone.ogg b/samples/pipelines/test/fixtures/test_tone.ogg new file mode 100644 index 00000000..818f8521 Binary files /dev/null and b/samples/pipelines/test/fixtures/test_tone.ogg differ diff --git a/samples/pipelines/test/fixtures/test_tone.ogg.license b/samples/pipelines/test/fixtures/test_tone.ogg.license new file mode 100644 index 00000000..45469f0a --- /dev/null +++ b/samples/pipelines/test/fixtures/test_tone.ogg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: © 2025 StreamKit Contributors + +SPDX-License-Identifier: CC0-1.0 diff --git a/samples/pipelines/test/nv_av1_colorbars/expected.toml b/samples/pipelines/test/nv_av1_colorbars/expected.toml new file mode 100644 index 00000000..287d611d --- /dev/null +++ b/samples/pipelines/test/nv_av1_colorbars/expected.toml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Expected output metadata for nv_av1_colorbars.yml +# Requires: skit built with --features nvcodec + NVIDIA GPU + +requires_node = "video::nv::av1_encoder" +output_extension = ".webm" +codec_name = "av1" +width = 320 +height = 240 +container_format = "matroska,webm" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/nv_av1_colorbars/pipeline.yml b/samples/pipelines/test/nv_av1_colorbars/pipeline.yml new file mode 100644 index 00000000..053654b8 --- /dev/null +++ b/samples/pipelines/test/nv_av1_colorbars/pipeline.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: NVENC AV1/WebM colorbars (30 frames = 1 second at 30fps). +# Used by headless pipeline validation tests. +# +# Requires: skit built with --features nvcodec +# NVIDIA GPU with NVENC AV1 support (Ada Lovelace / RTX 40+) + +name: Test — NVENC AV1 Color Bars +description: Short NVENC AV1/WebM colorbars for automated testing +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + nv_av1_encoder: + kind: video::nv::av1_encoder + params: + bitrate: 1000000 + framerate: 30 + needs: colorbars + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 320 + video_height: 240 + streaming_mode: live + needs: nv_av1_encoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="av1"' + needs: pacer diff --git a/samples/pipelines/test/openh264_colorbars/expected.toml b/samples/pipelines/test/openh264_colorbars/expected.toml new file mode 100644 index 00000000..21388536 --- /dev/null +++ b/samples/pipelines/test/openh264_colorbars/expected.toml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Expected output metadata for openh264_colorbars.yml + +output_extension = ".mp4" +codec_name = "h264" +width = 320 +height = 240 +container_format = "mov,mp4,m4a,3gp,3g2,mj2" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/openh264_colorbars/pipeline.yml b/samples/pipelines/test/openh264_colorbars/pipeline.yml new file mode 100644 index 00000000..e42618cc --- /dev/null +++ b/samples/pipelines/test/openh264_colorbars/pipeline.yml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: OpenH264/MP4 colorbars (30 frames = 1 second at 30fps). +# Used by headless pipeline validation tests. + +name: Test — OpenH264 Color Bars +description: Short H.264/MP4 colorbars for automated testing +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + h264_encoder: + kind: video::openh264::encoder + params: + bitrate_kbps: 1000 + max_frame_rate: 30.0 + needs: colorbars + + mp4_muxer: + kind: containers::mp4::muxer + params: + mode: stream + video_width: 320 + video_height: 240 + needs: h264_encoder + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/mp4; codecs="avc1.42c01f"' + needs: mp4_muxer diff --git a/samples/pipelines/test/opus_mp4/expected.toml b/samples/pipelines/test/opus_mp4/expected.toml new file mode 100644 index 00000000..81badb0e --- /dev/null +++ b/samples/pipelines/test/opus_mp4/expected.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +output_extension = ".mp4" +container_format = "mov,mp4,m4a,3gp,3g2,mj2" +audio_codec = "opus" +sample_rate = 48000 +channels = 1 +input_file = "../fixtures/test_tone.ogg" diff --git a/samples/pipelines/test/opus_mp4/pipeline.yml b/samples/pipelines/test/opus_mp4/pipeline.yml new file mode 100644 index 00000000..0ce01cd3 --- /dev/null +++ b/samples/pipelines/test/opus_mp4/pipeline.yml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: Opus in MP4 container. +# Exercises: ogg demuxer → opus decoder → opus encoder → mp4 muxer (file mode) +# Used by headless pipeline validation tests. + +name: Test — Opus in MP4 +description: Decodes uploaded Ogg/Opus, re-encodes to Opus, muxes into MP4 +mode: oneshot +client: + input: + type: file_upload + accept: "audio/opus" + output: + type: audio + +nodes: + http_input: + kind: streamkit::http_input + + ogg_demuxer: + kind: containers::ogg::demuxer + needs: http_input + + opus_decoder: + kind: audio::opus::decoder + needs: ogg_demuxer + + opus_encoder: + kind: audio::opus::encoder + needs: opus_decoder + + mp4_muxer: + kind: containers::mp4::muxer + params: + mode: file + sample_rate: 48000 + channels: 1 + needs: opus_encoder + + http_output: + kind: streamkit::http_output + params: + content_type: 'audio/mp4; codecs="opus"' + needs: mp4_muxer diff --git a/samples/pipelines/test/opus_roundtrip/expected.toml b/samples/pipelines/test/opus_roundtrip/expected.toml new file mode 100644 index 00000000..b4c40adc --- /dev/null +++ b/samples/pipelines/test/opus_roundtrip/expected.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +output_extension = ".ogg" +container_format = "ogg" +audio_codec = "opus" +sample_rate = 48000 +channels = 1 +input_file = "../fixtures/test_tone.ogg" diff --git a/samples/pipelines/test/opus_roundtrip/pipeline.yml b/samples/pipelines/test/opus_roundtrip/pipeline.yml new file mode 100644 index 00000000..0419fa3a --- /dev/null +++ b/samples/pipelines/test/opus_roundtrip/pipeline.yml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: Opus encode/decode roundtrip via Ogg container. +# Exercises: ogg demuxer → opus decoder → opus encoder → ogg muxer +# Used by headless pipeline validation tests. + +name: Test — Opus Roundtrip (Ogg) +description: Decodes uploaded Ogg/Opus, re-encodes to Opus, muxes into Ogg +mode: oneshot +client: + input: + type: file_upload + accept: "audio/opus" + output: + type: audio + +nodes: + http_input: + kind: streamkit::http_input + + ogg_demuxer: + kind: containers::ogg::demuxer + needs: http_input + + opus_decoder: + kind: audio::opus::decoder + needs: ogg_demuxer + + opus_encoder: + kind: audio::opus::encoder + needs: opus_decoder + + ogg_muxer: + kind: containers::ogg::muxer + params: + channels: 1 + chunk_size: 65536 + needs: opus_encoder + + http_output: + kind: streamkit::http_output + needs: ogg_muxer diff --git a/samples/pipelines/test/rav1e_colorbars/expected.toml b/samples/pipelines/test/rav1e_colorbars/expected.toml new file mode 100644 index 00000000..04fc7cb1 --- /dev/null +++ b/samples/pipelines/test/rav1e_colorbars/expected.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +requires_node = "video::av1::encoder" +output_extension = ".webm" +codec_name = "av1" +width = 320 +height = 240 +container_format = "matroska,webm" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/rav1e_colorbars/pipeline.yml b/samples/pipelines/test/rav1e_colorbars/pipeline.yml new file mode 100644 index 00000000..df1fa0da --- /dev/null +++ b/samples/pipelines/test/rav1e_colorbars/pipeline.yml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: rav1e AV1 encoder → WebM (30 frames = 1 second at 30fps). +# Exercises: colorbars → video::av1::encoder (rav1e, pure-Rust) → webm muxer +# Used by headless pipeline validation tests. + +name: Test — rav1e AV1 Color Bars +description: Short rav1e AV1/WebM colorbars for automated testing +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + av1_encoder: + kind: video::av1::encoder + params: + speed: 10 + low_latency: true + needs: colorbars + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 320 + video_height: 240 + streaming_mode: live + needs: av1_encoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="av1"' + needs: pacer diff --git a/samples/pipelines/test/svt_av1_colorbars/expected.toml b/samples/pipelines/test/svt_av1_colorbars/expected.toml new file mode 100644 index 00000000..afaa3247 --- /dev/null +++ b/samples/pipelines/test/svt_av1_colorbars/expected.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +requires_node = "video::svt_av1::encoder" +output_extension = ".webm" +codec_name = "av1" +width = 320 +height = 240 +container_format = "matroska,webm" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/svt_av1_colorbars/pipeline.yml b/samples/pipelines/test/svt_av1_colorbars/pipeline.yml new file mode 100644 index 00000000..0004467e --- /dev/null +++ b/samples/pipelines/test/svt_av1_colorbars/pipeline.yml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: SVT-AV1/WebM colorbars (30 frames = 1 second at 30fps). +# Used by headless pipeline validation tests. +# +# Requires: skit built with --features svt_av1 + +name: Test — SVT-AV1 Color Bars +description: Short SVT-AV1/WebM colorbars for automated testing +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + svt_av1_encoder: + kind: video::svt_av1::encoder + params: + preset: 12 + low_latency: true + needs: colorbars + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 320 + video_height: 240 + streaming_mode: live + needs: svt_av1_encoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="av1"' + needs: pacer diff --git a/samples/pipelines/test/vp9_colorbars/expected.toml b/samples/pipelines/test/vp9_colorbars/expected.toml new file mode 100644 index 00000000..2df03262 --- /dev/null +++ b/samples/pipelines/test/vp9_colorbars/expected.toml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Expected output metadata for vp9_colorbars.yml + +output_extension = ".webm" +codec_name = "vp9" +width = 320 +height = 240 +container_format = "matroska,webm" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/vp9_colorbars/pipeline.yml b/samples/pipelines/test/vp9_colorbars/pipeline.yml new file mode 100644 index 00000000..582d8d94 --- /dev/null +++ b/samples/pipelines/test/vp9_colorbars/pipeline.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: VP9/WebM colorbars (30 frames = 1 second at 30fps). +# Used by headless pipeline validation tests. + +name: Test — VP9 Color Bars +description: Short VP9/WebM colorbars for automated testing +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + vp9_encoder: + kind: video::vp9::encoder + needs: colorbars + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 320 + video_height: 240 + streaming_mode: live + needs: vp9_encoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="vp9"' + needs: pacer diff --git a/samples/pipelines/test/vp9_roundtrip/expected.toml b/samples/pipelines/test/vp9_roundtrip/expected.toml new file mode 100644 index 00000000..b8322c5a --- /dev/null +++ b/samples/pipelines/test/vp9_roundtrip/expected.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +output_extension = ".webm" +codec_name = "vp9" +width = 320 +height = 240 +container_format = "matroska,webm" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/vp9_roundtrip/pipeline.yml b/samples/pipelines/test/vp9_roundtrip/pipeline.yml new file mode 100644 index 00000000..062d1804 --- /dev/null +++ b/samples/pipelines/test/vp9_roundtrip/pipeline.yml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: VP9 encode → decode → re-encode roundtrip (30 frames). +# Exercises: colorbars → vp9 encoder → vp9 decoder → vp9 re-encoder → webm muxer +# Used by headless pipeline validation tests. + +name: Test — VP9 Encode/Decode Roundtrip +description: VP9 roundtrip via encode → decode → re-encode +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + vp9_encoder: + kind: video::vp9::encoder + needs: colorbars + + vp9_decoder: + kind: video::vp9::decoder + needs: vp9_encoder + + vp9_reencoder: + kind: video::vp9::encoder + needs: vp9_decoder + + webm_muxer: + kind: containers::webm::muxer + params: + video_width: 320 + video_height: 240 + streaming_mode: live + needs: vp9_reencoder + + pacer: + kind: core::pacer + needs: webm_muxer + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/webm; codecs="vp9"' + needs: pacer diff --git a/samples/pipelines/test/vulkan_video_h264_colorbars/expected.toml b/samples/pipelines/test/vulkan_video_h264_colorbars/expected.toml new file mode 100644 index 00000000..21b048d1 --- /dev/null +++ b/samples/pipelines/test/vulkan_video_h264_colorbars/expected.toml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Expected output metadata for vulkan_video_h264_colorbars.yml +# Requires: skit built with --features vulkan_video + Vulkan-capable GPU + +requires_node = "video::vulkan_video::h264_encoder" +output_extension = ".mp4" +codec_name = "h264" +width = 320 +height = 240 +container_format = "mov,mp4,m4a,3gp,3g2,mj2" +pix_fmt = "yuv420p" diff --git a/samples/pipelines/test/vulkan_video_h264_colorbars/pipeline.yml b/samples/pipelines/test/vulkan_video_h264_colorbars/pipeline.yml new file mode 100644 index 00000000..46491815 --- /dev/null +++ b/samples/pipelines/test/vulkan_video_h264_colorbars/pipeline.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +# Test pipeline: Vulkan Video H.264/MP4 colorbars (30 frames = 1 second at 30fps). +# Used by headless pipeline validation tests. +# +# Requires: skit built with --features vulkan_video +# Vulkan-capable GPU with H.264 encode support + +name: Test — Vulkan Video H.264 Color Bars +description: Short Vulkan Video H.264/MP4 colorbars for automated testing +mode: oneshot +client: + input: + type: none + output: + type: video + +nodes: + colorbars: + kind: video::colorbars + params: + width: 320 + height: 240 + fps: 30 + frame_count: 30 + pixel_format: nv12 + draw_time: true + draw_time_use_pts: true + + vk_h264_encoder: + kind: video::vulkan_video::h264_encoder + params: + bitrate: 1000000 + framerate: 30 + needs: colorbars + + mp4_muxer: + kind: containers::mp4::muxer + params: + mode: stream + video_width: 320 + video_height: 240 + needs: vk_h264_encoder + + http_output: + kind: streamkit::http_output + params: + content_type: 'video/mp4; codecs="avc1.42c01f"' + needs: mp4_muxer diff --git a/tests/pipeline-validation/Cargo.lock b/tests/pipeline-validation/Cargo.lock new file mode 100644 index 00000000..d9d09f1a --- /dev/null +++ b/tests/pipeline-validation/Cargo.lock @@ -0,0 +1,1978 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "datatest-stable" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a867d7322eb69cf3a68a5426387a25b45cb3b9c5ee41023ee6cea92e2afadd82" +dependencies = [ + "camino", + "fancy-regex", + "libtest-mimic", + "walkdir", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ffprobe" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffef835e1f9ac151db5bb2adbb95c9dfe1f315f987f011dd89cd655b4e9a52c" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pipeline-validation" +version = "0.1.0" +dependencies = [ + "datatest-stable", + "ffprobe", + "reqwest", + "serde", + "tempfile", + "toml", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/pipeline-validation/Cargo.toml b/tests/pipeline-validation/Cargo.toml new file mode 100644 index 00000000..26f57935 --- /dev/null +++ b/tests/pipeline-validation/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +[package] +name = "pipeline-validation" +version = "0.1.0" +edition = "2021" +publish = false +description = "Headless pipeline validation tests for StreamKit" + +# No library crate — this package only contains integration tests. +[lib] +name = "pipeline_validation" +path = "src/lib.rs" + +[[test]] +name = "validate" +path = "tests/validate.rs" +harness = false + +[dependencies] +ffprobe = "0.4" +reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +tempfile = "3" + +[dev-dependencies] +datatest-stable = "0.3.3" diff --git a/tests/pipeline-validation/src/lib.rs b/tests/pipeline-validation/src/lib.rs new file mode 100644 index 00000000..4daedea6 --- /dev/null +++ b/tests/pipeline-validation/src/lib.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Headless pipeline validation helpers for StreamKit. +//! +//! This crate provides utilities for running oneshot pipelines against a live +//! `skit` server, capturing the output, and validating it with `ffprobe`. +//! No browser required. +//! +//! # Architecture +//! +//! Each test case is defined by a pair of files in `samples/pipelines/test/`: +//! +//! - `.yml` — the pipeline YAML to POST to `/api/v1/process` +//! - `.toml` — expected output metadata (codec, resolution, container) +//! +//! The test harness discovers all `.yml` files, loads the companion `.toml`, +//! runs the pipeline, and validates the output with `ffprobe`. + +use std::collections::HashSet; +use std::io::Write; +use std::path::Path; + +use serde::Deserialize; + +/// Expected output metadata for a pipeline test case. +/// +/// Lives in a `.toml` sidecar file alongside each test pipeline YAML. +#[derive(Debug, Deserialize)] +pub struct Expected { + /// File extension for the output (e.g. ".webm", ".mp4", ".ogg"). + pub output_extension: String, + + /// Expected container format name from ffprobe (e.g. "matroska,webm", "mov,mp4,m4a,3gp,3g2,mj2"). + pub container_format: String, + + /// Optional: node kind that must be registered in the server for this test + /// to run. If the node is missing, the test is skipped (returns Ok). + pub requires_node: Option, + + // --- Video expectations (optional — omit for audio-only tests) --- + + /// Expected video codec name as reported by ffprobe (e.g. "vp9", "h264", "av1"). + pub codec_name: Option, + + /// Expected video width in pixels. + pub width: Option, + + /// Expected video height in pixels. + pub height: Option, + + /// Optional: expected pixel format (e.g. "yuv420p"). + pub pix_fmt: Option, + + // --- Audio expectations (optional — omit for video-only tests) --- + + /// Expected audio codec name as reported by ffprobe (e.g. "opus", "mp3", "flac"). + pub audio_codec: Option, + + /// Expected audio sample rate in Hz (e.g. 48000). + pub sample_rate: Option, + + /// Expected number of audio channels (e.g. 2). + pub channels: Option, + + // --- Input file (optional — for pipelines that accept file uploads) --- + + /// Relative path (from the test directory) to an input file to upload. + /// If set, the pipeline will be run with a file upload instead of no input. + pub input_file: Option, +} + +/// Get the base URL for the skit server from environment. +/// +/// Checks `PIPELINE_TEST_URL` first, then `E2E_BASE_URL`, and defaults to +/// `http://127.0.0.1:4545`. +pub fn get_base_url() -> String { + std::env::var("PIPELINE_TEST_URL") + .or_else(|_| std::env::var("E2E_BASE_URL")) + .unwrap_or_else(|_| "http://127.0.0.1:4545".to_string()) +} + +/// Query the server's node schema endpoint to discover which node kinds are +/// registered. Returns a set of node kind strings. +/// +/// This is used to skip tests for HW codecs that aren't compiled into the +/// running server binary (e.g. `video::nv::av1_encoder`). +pub fn get_available_nodes(base_url: &str) -> Result, String> { + let url = format!("{base_url}/api/v1/schema/nodes"); + let response = reqwest::blocking::get(&url) + .map_err(|e| format!("Failed to query node schema at {url}: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "Node schema request failed with status {}", + response.status() + )); + } + + #[derive(Deserialize)] + struct NodeInfo { + kind: String, + } + + let nodes: Vec = response + .json() + .map_err(|e| format!("Failed to parse node schema JSON: {e}"))?; + + Ok(nodes.into_iter().map(|n| n.kind).collect()) +} + +/// Run a oneshot pipeline against the skit server. +/// +/// Posts the pipeline YAML as a multipart form to `/api/v1/process` and +/// saves the streamed response body to a temporary file. +/// +/// If `input_file` is `Some`, the file is attached as the `media` part of the +/// multipart form (for pipelines that expect `client.input.type: file_upload`). +/// +/// Returns the path to the output file on success. +pub fn run_pipeline( + base_url: &str, + yaml_contents: &str, + output_extension: &str, + input_file: Option<&Path>, +) -> Result { + let url = format!("{base_url}/api/v1/process"); + + let mut form = reqwest::blocking::multipart::Form::new() + .text("config", yaml_contents.to_string()); + + // Attach the input file if provided. + if let Some(path) = input_file { + let file_bytes = std::fs::read(path) + .map_err(|e| format!("Failed to read input file {}: {e}", path.display()))?; + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("input") + .to_string(); + let part = reqwest::blocking::multipart::Part::bytes(file_bytes) + .file_name(file_name); + form = form.part("media", part); + } + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {e}"))?; + + let response = client + .post(&url) + .multipart(form) + .send() + .map_err(|e| format!("Pipeline request to {url} failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "".to_string()); + return Err(format!("Pipeline returned {status}: {body}")); + } + + // Stream response to a temp file. + let mut tmp = tempfile::Builder::new() + .suffix(output_extension) + .tempfile() + .map_err(|e| format!("Failed to create temp file: {e}"))?; + + let bytes = response + .bytes() + .map_err(|e| format!("Failed to read response body: {e}"))?; + + if bytes.is_empty() { + return Err( + "Pipeline returned HTTP 200 but the response body is empty. \ + This usually means the encoder failed to produce output \ + (e.g. the GPU does not support the required codec via this API)." + .to_string(), + ); + } + + tmp.write_all(&bytes) + .map_err(|e| format!("Failed to write output to temp file: {e}"))?; + + tmp.flush() + .map_err(|e| format!("Failed to flush temp file: {e}"))?; + + Ok(tmp) +} + +/// Validate a pipeline's output file against the expected metadata. +/// +/// Runs `ffprobe` against the output file and checks codec, resolution, +/// container format, and audio properties against the [`Expected`] values. +pub fn validate_output(output_path: &Path, expected: &Expected) -> Result<(), String> { + let file_size = std::fs::metadata(output_path) + .map(|m| m.len()) + .unwrap_or(0); + + let probe = ffprobe::ffprobe(output_path).map_err(|e| { + format!( + "ffprobe failed on {} ({file_size} bytes): {e}", + output_path.display() + ) + })?; + + // Check container format. + let format_name = &probe.format.format_name; + if !format_name.contains(&expected.container_format) + && !expected.container_format.contains(format_name.as_str()) + { + return Err(format!( + "Container mismatch: expected format containing '{}', got '{}'", + expected.container_format, format_name + )); + } + + // --- Video validation (if video expectations are set) --- + if let Some(ref expected_codec) = expected.codec_name { + let video = probe + .streams + .iter() + .find(|s| s.codec_type.as_deref() == Some("video")) + .ok_or("No video stream found in output (expected video codec)")?; + + let codec_name = video.codec_name.as_deref().unwrap_or(""); + if codec_name != expected_codec.as_str() { + return Err(format!( + "Video codec mismatch: expected '{}', got '{}'", + expected_codec, codec_name + )); + } + + if let Some(expected_w) = expected.width { + let width = video.width.unwrap_or(0) as u32; + if width != expected_w { + return Err(format!( + "Video width mismatch: expected {expected_w}, got {width}" + )); + } + } + + if let Some(expected_h) = expected.height { + let height = video.height.unwrap_or(0) as u32; + if height != expected_h { + return Err(format!( + "Video height mismatch: expected {expected_h}, got {height}" + )); + } + } + + if let Some(ref expected_pix_fmt) = expected.pix_fmt { + let pix_fmt = video.pix_fmt.as_deref().unwrap_or(""); + if pix_fmt != expected_pix_fmt.as_str() { + return Err(format!( + "Pixel format mismatch: expected '{}', got '{}'", + expected_pix_fmt, pix_fmt + )); + } + } + } + + // --- Audio validation (if audio expectations are set) --- + if let Some(ref expected_audio_codec) = expected.audio_codec { + let audio = probe + .streams + .iter() + .find(|s| s.codec_type.as_deref() == Some("audio")) + .ok_or("No audio stream found in output (expected audio codec)")?; + + let codec_name = audio.codec_name.as_deref().unwrap_or(""); + if codec_name != expected_audio_codec.as_str() { + return Err(format!( + "Audio codec mismatch: expected '{}', got '{}'", + expected_audio_codec, codec_name + )); + } + + if let Some(expected_sr) = expected.sample_rate { + // ffprobe reports sample_rate as a string in the stream. + let actual_sr = audio + .sample_rate + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + if actual_sr != expected_sr { + return Err(format!( + "Sample rate mismatch: expected {expected_sr}, got {actual_sr}" + )); + } + } + + if let Some(expected_ch) = expected.channels { + let actual_ch = audio.channels.unwrap_or(0) as u32; + if actual_ch != expected_ch { + return Err(format!( + "Channel count mismatch: expected {expected_ch}, got {actual_ch}" + )); + } + } + } + + // Ensure at least one stream type was validated. + if expected.codec_name.is_none() && expected.audio_codec.is_none() { + return Err( + "Expected TOML must specify at least one of 'codec_name' (video) or 'audio_codec' (audio)" + .to_string(), + ); + } + + Ok(()) +} + + diff --git a/tests/pipeline-validation/tests/validate.rs b/tests/pipeline-validation/tests/validate.rs new file mode 100644 index 00000000..dfeeddf8 --- /dev/null +++ b/tests/pipeline-validation/tests/validate.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Headless pipeline validation test harness. +//! +//! Discovers all `pipeline.yml` files under `samples/pipelines/test//`, +//! loads the sibling `expected.toml`, runs the pipeline against a live `skit` +//! server, and validates the output with `ffprobe`. +//! +//! # Usage +//! +//! ```bash +//! # Start skit, then run tests: +//! PIPELINE_TEST_URL=http://127.0.0.1:4545 \ +//! cargo test --manifest-path tests/pipeline-validation/Cargo.toml +//! +//! # Or via justfile: +//! just test-pipelines http://127.0.0.1:4545 +//! ``` + +#![allow(clippy::disallowed_macros)] // Test binary — no logging crate available. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::OnceLock; + +use pipeline_validation::{ + get_available_nodes, get_base_url, run_pipeline, validate_output, Expected, +}; + +/// Lazily resolved base URL for the skit server. +fn base_url() -> &'static str { + static URL: OnceLock = OnceLock::new(); + URL.get_or_init(get_base_url) +} + +/// Lazily resolved set of available node kinds on the server. +/// +/// When `PIPELINE_REQUIRE_NODES=1` is set, failure to query the schema +/// endpoint panics instead of returning an empty set — this prevents +/// a broken server from silently skipping all HW-codec tests. +fn available_nodes() -> &'static HashSet { + static NODES: OnceLock> = OnceLock::new(); + NODES.get_or_init(|| { + get_available_nodes(base_url()).unwrap_or_else(|err| { + if std::env::var("PIPELINE_REQUIRE_NODES").as_deref() == Ok("1") { + panic!( + "FATAL: Could not query available nodes: {err}\n \ + PIPELINE_REQUIRE_NODES=1 requires a reachable server at {}", + base_url() + ); + } + eprintln!("WARNING: Could not query available nodes: {err}"); + eprintln!(" HW codec tests will be skipped."); + eprintln!(" Is skit running at {}?", base_url()); + HashSet::new() + }) + }) +} + +/// The main test function called by `datatest-stable` for each `pipeline.yml`. +/// +/// For each test directory it: +/// 1. Loads the sibling `expected.toml` +/// 2. Checks if the required node kind is available (skips if not) +/// 3. POSTs the pipeline to the server +/// 4. Saves the response to a temp file +/// 5. Validates the output with `ffprobe` +fn validate_pipeline(path: &Path, yaml: String) -> datatest_stable::Result<()> { + // Load sibling expectations file from the same directory. + let test_dir = path.parent().expect("pipeline.yml must be inside a test directory"); + let expected_path = test_dir.join("expected.toml"); + let expected_toml = std::fs::read_to_string(&expected_path).map_err(|e| { + format!( + "Missing expectations file '{}': {e}\n\ + Each test pipeline YAML needs a companion .toml with expected output metadata.", + expected_path.display() + ) + })?; + + let expected: Expected = toml::from_str(&expected_toml).map_err(|e| { + format!( + "Invalid expectations file '{}': {e}", + expected_path.display() + ) + })?; + + // Check if the required node is available. + // + // When `PIPELINE_REQUIRE_NODES=1` is set (typically in CI jobs that + // explicitly compile with the required features), a missing node is + // treated as a test failure instead of a silent skip. This prevents + // registration regressions from producing false-green CI runs. + if let Some(ref required) = expected.requires_node { + if !available_nodes().contains(required.as_str()) { + if std::env::var("PIPELINE_REQUIRE_NODES").as_deref() == Ok("1") { + return Err(format!( + "FAIL: '{}' requires node '{}' which is not available on this server \ + (PIPELINE_REQUIRE_NODES=1 — skipping is not allowed)", + path.display(), + required + ) + .into()); + } + eprintln!( + "SKIP: '{}' requires node '{}' which is not available on this server", + path.display(), + required + ); + return Ok(()); + } + } + + let test_name = test_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Resolve the input file path (if any) relative to the test directory. + let input_file = expected.input_file.as_ref().map(|rel| test_dir.join(rel)); + let input_ref = input_file.as_deref(); + + // Run the pipeline. + let output = run_pipeline(base_url(), &yaml, &expected.output_extension, input_ref) + .map_err(|e| format!("Pipeline '{test_name}' failed: {e}"))?; + + // Validate with ffprobe. + validate_output(output.path(), &expected) + .map_err(|e| format!("Validation failed for '{test_name}': {e}"))?; + + Ok(()) +} + +datatest_stable::harness! { + { test = validate_pipeline, root = "../../samples/pipelines/test", pattern = r"pipeline\.yml$" }, +}